Merge nucleic/sleek-thistle-egret-fyej into dev

This commit is contained in:
2026-07-18 05:19:31 -07:00
commit b6be87b72d
677 changed files with 102939 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
//! Filesystem utilities
pub use super::platform::fs::*;
/// Extension trait for path-related filesystem operations.
pub trait PathExt {
/// Returns true if the path exists and is readable by the current user.
fn readable(&self) -> bool;
/// Returns true if the path exists and is writable by the current user.
fn writable(&self) -> bool;
/// Returns true if the path exists and is executable by the current user.
///
/// On Windows, this returns true if *either* the path itself is a file with
/// a `PATHEXT` extension *or* appending some `PATHEXT` extension resolves
/// to an existing file. To recover the actual on-disk path in the
/// latter case, use [`resolve_executable`] which takes ownership
/// and avoids copies on platforms where no resolution is needed.
fn executable(&self) -> bool;
/// Returns true if the path exists and is a block device.
fn exists_and_is_block_device(&self) -> bool;
/// Returns true if the path exists and is a character device.
fn exists_and_is_char_device(&self) -> bool;
/// Returns true if the path exists and is a FIFO (named pipe).
fn exists_and_is_fifo(&self) -> bool;
/// Returns true if the path exists and is a socket.
fn exists_and_is_socket(&self) -> bool;
/// Returns true if the path exists and has the setgid bit set.
fn exists_and_is_setgid(&self) -> bool;
/// Returns true if the path exists and has the setuid bit set.
fn exists_and_is_setuid(&self) -> bool;
/// Returns true if the path exists and has the sticky bit set.
fn exists_and_is_sticky_bit(&self) -> bool;
/// Returns the device ID and inode number for the path.
fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error>;
}
+3
View File
@@ -0,0 +1,3 @@
pub(crate) fn get() -> std::io::Result<std::ffi::OsString> {
hostname::get()
}
+22
View File
@@ -0,0 +1,22 @@
#![allow(dead_code)]
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::needless_pass_by_ref_mut)]
#![allow(clippy::needless_pass_by_value)]
#![allow(clippy::unnecessary_wraps)]
#![allow(clippy::unused_async)]
#![allow(clippy::unused_self)]
pub mod async_pipe;
pub mod commands;
pub(crate) mod env;
pub mod fd;
pub mod fs;
pub mod input;
pub(crate) mod network;
pub(crate) mod pipes;
pub mod poll;
pub mod process;
pub mod resource;
pub mod signal;
pub mod terminal;
pub(crate) mod users;
+30
View File
@@ -0,0 +1,30 @@
//! Async pipe reading utilities for non-Unix platforms.
//!
//! Uses `spawn_blocking` internally for the I/O operation only,
//! not for the entire subshell execution.
use std::io::{self, Read};
pub(crate) struct AsyncPipeReader {
inner: Option<std::io::PipeReader>,
}
impl AsyncPipeReader {
pub(crate) fn new(fd: std::io::PipeReader) -> io::Result<Self> {
Ok(Self { inner: Some(fd) })
}
pub(crate) async fn read_to_string(&mut self) -> io::Result<String> {
let Some(reader) = self.inner.take() else {
return Ok(String::new());
};
tokio::task::spawn_blocking(move || {
let mut s = String::new();
{ reader }.read_to_string(&mut s)?;
Ok(s)
})
.await
.map_err(io::Error::other)?
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Command execution utilities.
use std::ffi::OsStr;
use crate::{ShellFd, error, openfiles};
/// Extension trait for Unix-like command extensions.
pub trait CommandExt {
/// Sets the zeroth argument (argv[0]) of the command.
///
/// # Arguments
///
/// * `arg` - The argument to set as argv[0].
fn arg0<S>(&mut self, arg: S) -> &mut Self
where
S: AsRef<OsStr>;
/// Sets the process group ID of the command.
///
/// # Arguments
///
/// * `pgroup` - The process group ID to set.
fn process_group(&mut self, pgroup: i32) -> &mut Self;
}
impl CommandExt for std::process::Command {
fn arg0<S>(&mut self, _arg: S) -> &mut Self
where
S: AsRef<OsStr>,
{
// NOTE: no-op.
self
}
fn process_group(&mut self, _pgroup: i32) -> &mut Self {
// NOTE: no-op.
self
}
}
/// Extension trait for Unix-like exit status extensions.
pub trait ExitStatusExt {
/// Returns the signal that terminated the process, if any.
fn signal(&self) -> Option<i32>;
}
impl ExitStatusExt for std::process::ExitStatus {
fn signal(&self) -> Option<i32> {
None
}
}
/// Extension trait for injecting file descriptors into commands.
pub trait CommandFdInjectionExt {
/// Injects the given open files as file descriptors into the command.
///
/// # Arguments
///
/// * `open_files` - A mapping of child file descriptors to open files.
fn inject_fds(
&mut self,
open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error>;
}
impl CommandFdInjectionExt for std::process::Command {
fn inject_fds(
&mut self,
mut open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error> {
if open_files.next().is_some() {
return Err(error::ErrorKind::NotSupportedOnThisPlatform("fd redirections").into());
}
Ok(())
}
}
/// Extension trait for arranging for commands to take the foreground.
pub trait CommandFgControlExt {
/// Arranges for the command to take the foreground when it is executed.
fn take_foreground(&mut self);
/// Arranges for the command to become a session leader when it is executed.
fn lead_session(&mut self);
}
impl CommandFgControlExt for std::process::Command {
fn take_foreground(&mut self) {
// NOTE: This is a no-op.
}
fn lead_session(&mut self) {
// NOTE: This is a no-op.
}
}
+8
View File
@@ -0,0 +1,8 @@
//! Environment variable retrieval (stub implementation).
/// Retrieves environment variables from the host process.
///
/// Stub implementation that returns no variables.
pub(crate) fn get_host_env_vars() -> impl Iterator<Item = (String, String)> {
std::iter::empty()
}
+13
View File
@@ -0,0 +1,13 @@
//! File descriptor utilities.
use crate::{ShellFd, error, openfiles};
/// Stub implementation for platforms that do not support enumerating file descriptors.
pub fn try_iter_open_fds() -> impl Iterator<Item = (ShellFd, openfiles::OpenFile)> {
std::iter::empty()
}
/// Stub implementation for platforms that do not support opening file descriptors.
pub fn try_get_file_for_open_fd(_fd: ShellFd) -> Option<openfiles::OpenFile> {
None
}
+136
View File
@@ -0,0 +1,136 @@
//! Filesystem utilities (stubs).
use crate::error;
pub(crate) trait MetadataExt {
fn gid(&self) -> u32 {
0
}
fn uid(&self) -> u32 {
0
}
}
impl MetadataExt for std::fs::Metadata {}
pub(crate) fn get_default_executable_search_paths() -> Vec<std::path::PathBuf> {
vec![]
}
/// Returns the default paths where standard Unix utilities are typically installed.
/// This is a stub implementation that returns an empty vector.
pub fn get_default_standard_utils_paths() -> Vec<std::path::PathBuf> {
vec![]
}
/// Opens a null file that will discard all I/O.
///
/// This is a stub implementation that returns an error.
pub fn open_null_file() -> Result<std::fs::File, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("opening null file").into())
}
/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`).
//
// This is a stub implementation that returns no result.
pub fn try_open_special_file(
_path: &std::path::Path,
) -> Option<Result<std::fs::File, std::io::Error>> {
None
}
/// Returns the path to the system-wide shell profile script.
///
/// Stub implementation that returns `None`.
pub fn get_system_profile_path() -> Option<&'static std::path::Path> {
None
}
/// Returns the path to the system-wide shell rc script.
///
/// Stub implementation that returns `None`.
pub fn get_system_rc_path() -> Option<&'static std::path::Path> {
None
}
/// Returns the platform default for case-insensitive pathname expansion.
///
/// In the stub implementation, this returns `false`.
pub const fn default_case_insensitive_path_expansion() -> bool {
false
}
/// Returns true if the string contains a path separator character.
///
/// In the stub implementation, only `/` is considered a path separator.
pub fn contains_path_separator(s: &str) -> bool {
s.contains('/')
}
/// Returns true if the string ends with a path separator character.
///
/// In the stub implementation, only `/` is considered a path separator.
pub fn ends_with_path_separator(s: &str) -> bool {
s.ends_with('/')
}
/// Returns the string with a trailing path separator removed, if present.
///
/// In the stub implementation, only `/` is considered a path separator.
pub fn strip_path_separator_suffix(s: &str) -> &str {
s.strip_suffix('/').unwrap_or(s)
}
/// Finds the byte index of the last path separator in the string.
///
/// In the stub implementation, only `/` is considered a path separator.
pub fn rfind_path_separator(s: &str) -> Option<usize> {
s.rfind('/')
}
/// Splits a string on path separator characters, returning an iterator of components.
///
/// In the stub implementation, only `/` is used as a separator.
pub fn split_path_for_pattern(s: &str) -> impl Iterator<Item = &str> {
s.split('/')
}
/// Returns the root path for an absolute pattern, if the first component indicates one.
///
/// In the stub implementation, an empty first component indicates an absolute path.
pub fn pattern_path_root(first_component: &str) -> Option<std::path::PathBuf> {
if first_component.is_empty() {
Some(std::path::PathBuf::from("/"))
} else {
None
}
}
/// Pushes a component onto a path for pattern expansion.
///
/// In the stub implementation, this delegates directly to `PathBuf::push`.
pub fn push_path_for_pattern(path: &mut std::path::PathBuf, component: &str) {
path.push(component);
}
/// Normalizes path separators for shell output.
///
/// In the stub implementation, this is a no-op.
pub const fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(s)
}
/// Resolves an owned path to the actual on-disk executable file, if any.
///
/// In the stub implementation, returns the path unchanged if it is
/// executable (per the stub `PathExt`, which considers every path
/// executable).
pub fn resolve_executable(path: std::path::PathBuf) -> Option<std::path::PathBuf> {
use crate::sys::fs::PathExt;
if path.as_path().executable() {
Some(path)
} else {
None
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Terminal input utilities
use crate::{error, interfaces};
/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None`
/// if the key code is not recognized.
///
/// This is a stub implementation that recognizes single-byte non-control
/// characters but does not support terminal-specific key sequences.
pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option<interfaces::Key> {
if key_code.len() == 1 && !key_code[0].is_ascii_control() {
Some(interfaces::Key::Character(key_code[0] as char))
} else {
None
}
}
+3
View File
@@ -0,0 +1,3 @@
pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
Ok("".into())
}
+53
View File
@@ -0,0 +1,53 @@
/// Stub implementation of a pipe reader.
#[derive(Clone)]
pub(crate) struct PipeReader {}
impl PipeReader {
/// Tries to clone the reader.
pub fn try_clone(&self) -> std::io::Result<Self> {
Ok((*self).clone())
}
}
impl From<PipeReader> for std::process::Stdio {
fn from(_reader: PipeReader) -> Self {
Self::null()
}
}
impl std::io::Read for PipeReader {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
Ok(0)
}
}
/// Stub implementation o a pipe writer.
#[derive(Clone)]
pub(crate) struct PipeWriter {}
impl PipeWriter {
/// Tries to clone the writer.
pub fn try_clone(&self) -> std::io::Result<Self> {
Ok((*self).clone())
}
}
impl From<PipeWriter> for std::process::Stdio {
fn from(_writer: PipeWriter) -> Self {
Self::null()
}
}
impl std::io::Write for PipeWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Ok(0)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(crate) fn pipe() -> std::io::Result<(PipeReader, PipeWriter)> {
Ok((PipeReader {}, PipeWriter {}))
}
+15
View File
@@ -0,0 +1,15 @@
//! Stub file descriptor polling utilities for platforms without poll support.
use std::time::Duration;
use crate::openfiles::OpenFile;
/// Stub implementation that always returns an unsupported error.
///
/// Timeout-based reading is not supported on this platform.
pub fn poll_for_input(_file: &OpenFile, _timeout: Duration) -> std::io::Result<bool> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"poll-based timeout is not supported on this platform",
))
}
+34
View File
@@ -0,0 +1,34 @@
//! Process management utilities
pub(crate) type ProcessId = i32;
/// Provides access to a child process.
pub struct Child {
inner: std::process::Child,
}
pub(crate) use std::process::ExitStatus;
pub(crate) use std::process::Output;
impl Child {
/// Returns the process ID of the child process, if available.
pub fn id(&self) -> Option<u32> {
None
}
/// Asynchronously waits for the child process to exit.
pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
self.inner.wait()
}
/// Asynchronously waits for the child process to exit and collects its
/// output.
pub async fn wait_with_output(self) -> std::io::Result<Output> {
self.inner.wait_with_output()
}
}
pub(crate) fn spawn(mut command: std::process::Command) -> std::io::Result<Child> {
let child = command.spawn()?;
Ok(Child { inner: child })
}
+19
View File
@@ -0,0 +1,19 @@
//! Signal processing utilities
use crate::error;
/// Returns the user and system CPU time used by the current process.
///
/// This is a stub implementation that returns zero durations.
pub fn get_self_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
Ok((std::time::Duration::ZERO, std::time::Duration::ZERO))
}
/// Returns the user and system CPU time used by child processes.
///
/// This is a stub implementation that returns zero durations.
pub fn get_children_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
Ok((std::time::Duration::ZERO, std::time::Duration::ZERO))
}
+84
View File
@@ -0,0 +1,84 @@
//! Signal processing utilities
use crate::{error, sys, traps};
/// A stub enum representing system signals on unsupported platforms.
#[allow(unnameable_types)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Signal {}
impl Signal {
/// Returns an iterator over all possible signals.
pub fn iterator() -> impl Iterator<Item = Self> {
std::iter::empty()
}
/// Converts the signal into its corresponding name as a `&'static str`.
pub const fn as_str(self) -> &'static str {
""
}
/// Creates a `Signal` from a string representation.
pub fn from_str(s: &str) -> Result<Self, error::Error> {
Err(error::ErrorKind::InvalidSignal(s.into()).into())
}
}
impl TryFrom<i32> for Signal {
type Error = error::Error;
fn try_from(value: i32) -> Result<Self, Self::Error> {
Err(error::ErrorKind::InvalidSignal(std::format!("{value}")).into())
}
}
pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("continuing process").into())
}
/// Sends a signal to a specific process.
///
/// This is a stub implementation that returns an error.
pub fn kill_process(
_pid: sys::process::ProcessId,
_signal: traps::TrapSignal,
) -> Result<(), error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("killing process").into())
}
pub(crate) fn lead_new_process_group() -> Result<(), error::Error> {
Ok(())
}
pub(crate) struct FakeSignal {}
impl FakeSignal {
fn new() -> Self {
Self {}
}
pub async fn recv(&self) {
futures::future::pending::<()>().await;
}
}
pub(crate) fn tstp_signal_listener() -> Result<FakeSignal, error::Error> {
Ok(FakeSignal::new())
}
pub(crate) fn chld_signal_listener() -> Result<FakeSignal, error::Error> {
Ok(FakeSignal::new())
}
pub(crate) async fn await_ctrl_c() -> std::io::Result<()> {
FakeSignal::new().recv().await;
Ok(())
}
pub(crate) fn mask_sigttou() -> Result<(), error::Error> {
Ok(())
}
pub(crate) fn poll_for_stopped_children() -> Result<bool, error::Error> {
Ok(false)
}
+79
View File
@@ -0,0 +1,79 @@
//! Terminal utilities.
use crate::{error, openfiles, sys, terminal};
/// Terminal configuration.
#[derive(Clone, Debug)]
pub struct Config;
#[allow(clippy::unused_self)]
impl Config {
/// Creates a new `Config` from the actual terminal attributes of the terminal associated
/// with the given file descriptor.
///
/// # Arguments
///
/// * `_file` - A reference to the open terminal.
pub fn from_term(_file: &openfiles::OpenFile) -> Result<Self, error::Error> {
Ok(Self)
}
/// Applies the terminal settings to the terminal associated with the given file descriptor.
///
/// # Arguments
///
/// * `_file` - A reference to the open terminal.
pub fn apply_to_term(&self, _file: &openfiles::OpenFile) -> Result<(), error::Error> {
Ok(())
}
/// Applies the given high-level terminal settings to this configuration. Does not modify any
/// terminal itself.
///
/// # Arguments
///
/// * `_settings` - The high-level terminal settings to apply to this configuration.
pub fn update(&mut self, _settings: &terminal::Settings) {}
}
/// Get the process ID of this process's parent.
///
/// This is a stub implementation that returns `None`.
pub fn get_parent_process_id() -> Option<sys::process::ProcessId> {
None
}
/// Get the process group ID for this process's process group.
///
/// This is a stub implementation that returns `None`.
pub fn get_process_group_id() -> Option<sys::process::ProcessId> {
None
}
/// Get the foreground process ID of the attached terminal.
///
/// This is a stub implementation that returns `None`.
pub fn get_foreground_pid() -> Option<sys::process::ProcessId> {
None
}
/// Move the specified process to the foreground of the attached terminal.
///
/// This is a stub implementation that takes no action.
pub fn move_to_foreground(_pid: sys::process::ProcessId) -> Result<(), error::Error> {
Ok(())
}
/// Moves the current process to the foreground of the attached terminal.
///
/// This is a stub implementation that returns `None`.
pub fn move_self_to_foreground() -> Result<(), std::io::Error> {
Ok(())
}
/// Tries to get the path of the terminal device associated with the attached terminal.
///
/// This is a stub implementation that always returns `None`.
pub fn try_get_terminal_device_path() -> Option<std::path::PathBuf> {
None
}
+50
View File
@@ -0,0 +1,50 @@
use crate::error;
use std::path::PathBuf;
pub(crate) fn get_user_home_dir(_username: &str) -> Option<PathBuf> {
None
}
pub(crate) fn get_current_user_home_dir() -> Option<PathBuf> {
std::env::home_dir()
}
pub(crate) fn get_current_user_default_shell() -> Option<PathBuf> {
None
}
pub(crate) fn is_root() -> bool {
false
}
pub(crate) fn get_current_uid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current uid").into())
}
pub(crate) fn get_current_gid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current gid").into())
}
pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective uid").into())
}
pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective gid").into())
}
pub(crate) fn get_current_username() -> Result<String, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current username").into())
}
pub(crate) fn get_user_group_ids() -> Result<Vec<u32>, error::Error> {
Ok(vec![])
}
pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
Ok(vec![])
}
pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
Ok(vec![])
}
+9
View File
@@ -0,0 +1,9 @@
//! Process management utilities
pub(crate) type ProcessId = i32;
pub(crate) use tokio::process::Child;
pub(crate) fn spawn(command: std::process::Command) -> std::io::Result<Child> {
let mut command = tokio::process::Command::from(command);
command.spawn()
}
+28
View File
@@ -0,0 +1,28 @@
pub mod async_pipe;
pub mod commands;
pub(crate) mod env;
pub mod fd;
pub mod fs;
pub mod input;
pub(crate) mod network;
pub mod poll;
use crate::error;
pub use crate::sys::tokio_process as process;
pub mod resource;
pub mod signal;
pub mod terminal;
pub(crate) mod users;
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
pub enum PlatformError {
/// A system error occurred.
#[error("system error: {0}")]
ErrnoError(#[from] nix::errno::Errno),
}
impl From<nix::errno::Errno> for error::ErrorKind {
fn from(err: nix::errno::Errno) -> Self {
PlatformError::ErrnoError(err).into()
}
}
+23
View File
@@ -0,0 +1,23 @@
//! Async pipe reading utilities for Unix.
use std::io;
use std::os::unix::io::OwnedFd;
use tokio::net::unix::pipe;
pub(crate) struct AsyncPipeReader(pipe::Receiver);
impl AsyncPipeReader {
pub(crate) fn new(reader: std::io::PipeReader) -> io::Result<Self> {
Ok(Self(pipe::Receiver::from_file(std::fs::File::from(
OwnedFd::from(reader),
))?))
}
pub(crate) async fn read_to_string(&mut self) -> io::Result<String> {
use tokio::io::AsyncReadExt;
let mut s = String::new();
self.0.read_to_string(&mut s).await?;
Ok(s)
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Command execution utilities.
pub use std::os::unix::process::CommandExt;
pub use std::os::unix::process::ExitStatusExt;
use command_fds::{CommandFdExt, FdMapping};
use crate::ShellFd;
use crate::error;
use crate::openfiles;
/// Extension trait for injecting file descriptors into commands.
pub trait CommandFdInjectionExt {
/// Injects the given open files as file descriptors into the command.
///
/// # Arguments
///
/// * `open_files` - A mapping of child file descriptors to open files.
fn inject_fds(
&mut self,
open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error>;
}
impl CommandFdInjectionExt for std::process::Command {
fn inject_fds(
&mut self,
open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error> {
let fd_mappings: Vec<FdMapping> = open_files
.map(|(child_fd, open_file)| -> Result<FdMapping, error::Error> {
let parent_fd = open_file.try_clone_to_owned()?;
Ok(FdMapping {
child_fd,
parent_fd,
})
})
.collect::<Result<Vec<_>, _>>()?;
self.fd_mappings(fd_mappings)
.map_err(|_e| error::ErrorKind::ChildCreationFailure)?;
Ok(())
}
}
/// Extension trait for arranging for commands to take the foreground.
pub trait CommandFgControlExt {
/// Arranges for the command to take the foreground when it is executed.
fn take_foreground(&mut self);
/// Arranges for the command to become a session leader when it is executed.
fn lead_session(&mut self);
}
impl CommandFgControlExt for std::process::Command {
fn take_foreground(&mut self) {
// SAFETY:
// This arranges for a provided function to run in the context of
// the forked process before it exec's the target command. In general,
// rust can't guarantee safety of code running in such a context.
unsafe {
self.pre_exec(pre_exec_take_foreground);
}
}
fn lead_session(&mut self) {
// SAFETY:
// This arranges for a provided function to run in the context of
// the forked process before it exec's the target command. In general,
// rust can't guarantee safety of code running in such a context.
unsafe {
self.pre_exec(pre_exec_lead_session);
}
}
}
fn pre_exec_take_foreground() -> Result<(), std::io::Error> {
use crate::sys;
sys::terminal::move_self_to_foreground()?;
Ok(())
}
fn pre_exec_lead_session() -> Result<(), std::io::Error> {
if let Err(e) = nix::unistd::setsid() {
return Err(std::io::Error::other(format!(
"failed to become session leader: {e}"
)));
}
#[cfg(not(target_os = "macos"))]
let control = libc::TIOCSCTTY;
#[cfg(target_os = "macos")]
let control: u64 = libc::TIOCSCTTY.into();
// SAFETY:
// This is calling a libc function to set the controlling terminal.
let result = unsafe { libc::ioctl(0, control, 0) };
if result != 0 {
return Err(std::io::Error::other("failed to set controlling terminal"));
}
Ok(())
}
+8
View File
@@ -0,0 +1,8 @@
//! Environment variable retrieval for Unix platforms.
/// Retrieves environment variables from the host process.
///
/// On Unix, this is a direct passthrough to [`std::env::vars()`].
pub(crate) fn get_host_env_vars() -> impl Iterator<Item = (String, String)> {
std::env::vars()
}
+87
View File
@@ -0,0 +1,87 @@
//! File descriptor utilities.
use std::os::fd::RawFd;
use crate::{ShellFd, error, openfiles};
cfg_if::cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "android"))] {
const FD_DIR_PATH: &str = "/proc/self/fd";
} else if #[cfg(any(
target_os = "freebsd",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd"
))] {
const FD_DIR_PATH: &str = "/dev/fd";
} else {
/// Returns an iterator over all open file descriptors for the shell.
pub fn iter_fds()
-> Result<impl Iterator<Item = (ShellFd, openfiles::OpenFile)>, error::Error> {
Ok(std::iter::empty())
}
}
}
/// Makes a best-effort attempt to iterate over all open file descriptors
/// for the current process.
///
/// If the platform does not support enumerating file descriptors, an empty iterator
/// is returned. This function will skip any file descriptors that cannot be opened.
#[cfg(any(
target_os = "linux",
target_os = "android",
target_os = "freebsd",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd"
))]
pub fn try_iter_open_fds() -> impl Iterator<Item = (ShellFd, openfiles::OpenFile)> {
std::fs::read_dir(FD_DIR_PATH)
.into_iter()
.flatten()
.filter_map(Result::ok)
.filter_map(|entry| {
let fd: RawFd = entry.file_name().to_str()?.parse().ok()?;
// SAFETY:
// We are trying to open the file descriptor we found listed
// in the filesystem, but there's a risk that it's not the same one
// that we enumerated or that it's since been closed. For the purposes
// of this function, either of those outcomes are acceptable. We
// simply skip any fds that we can't open, and the function's purpose
// is to make a best-effort attempt to open all available fds.
let file = unsafe { open_file_by_fd(fd) }.ok()?;
Some((fd, file))
})
}
/// Attempts to retrieve an `OpenFile` representation for the given already-open file descriptor.
///
/// If the file descriptor cannot be opened, `None` is returned. Note that there is no guarantee
/// that the returned file matches the original file descriptor, as the fd may have been closed
/// and potentially re-used in the meantime.
///
/// # Arguments
///
/// * `fd` - The file descriptor to open.
pub fn try_get_file_for_open_fd(fd: RawFd) -> Option<openfiles::OpenFile> {
// SAFETY:
// We are trying to open the file descriptor provided by the caller. There's a risk that the fd
// is invalid or has been closed since it was enumerated. For the purposes of this function,
// we simply return None if we can't open it. There's also a risk that the fd has been closed
// and re-used for a different file; again, for the purposes of this function, we accept that
// risk and document it as part of the function's contract.
unsafe { open_file_by_fd(fd).ok() }
}
unsafe fn open_file_by_fd(fd: RawFd) -> Result<openfiles::OpenFile, error::Error> {
// SAFETY: We are creating a BorrowedFd from a file descriptor. Callers typically
// enumerate available file descriptors from procfs, devfs, or similar, but there's
// still a risk that the fd has become invalid or closed since then -- or that this
// function gets used incorrectly.
let borrowed_fd = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
let owned_fd = borrowed_fd.try_clone_to_owned()?;
Ok(std::fs::File::from(owned_fd).into())
}
+413
View File
@@ -0,0 +1,413 @@
//! Filesystem utilities.
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::FileTypeExt;
use std::path::{Path, PathBuf};
use crate::error;
pub use std::os::unix::fs::MetadataExt;
#[cfg(target_os = "android")]
// _PATH_DEFPATH in https://android.googlesource.com/platform/bionic/+/refs/heads/main/libc/include/paths.h
const ANDROID_DEFPATH: &str = "/product/bin:/apex/com.android.runtime/bin:/apex/com.android.art/bin:/apex/com.android.virt/bin:/system_ext/bin:/system/bin:/system/xbin:/odm/bin:/vendor/bin:/vendor/xbin";
impl crate::sys::fs::PathExt for Path {
fn readable(&self) -> bool {
nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok()
}
fn writable(&self) -> bool {
nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok()
}
fn executable(&self) -> bool {
nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok()
}
fn exists_and_is_block_device(&self) -> bool {
try_get_file_type(self).is_some_and(|ft| ft.is_block_device())
}
fn exists_and_is_char_device(&self) -> bool {
try_get_file_type(self).is_some_and(|ft| ft.is_char_device())
}
fn exists_and_is_fifo(&self) -> bool {
try_get_file_type(self).is_some_and(|ft: std::fs::FileType| ft.is_fifo())
}
fn exists_and_is_socket(&self) -> bool {
try_get_file_type(self).is_some_and(|ft| ft.is_socket())
}
fn exists_and_is_setgid(&self) -> bool {
const S_ISGID: u32 = 0o2000;
let file_mode = try_get_file_mode(self);
file_mode.is_some_and(|mode| mode & S_ISGID != 0)
}
fn exists_and_is_setuid(&self) -> bool {
const S_ISUID: u32 = 0o4000;
let file_mode = try_get_file_mode(self);
file_mode.is_some_and(|mode| mode & S_ISUID != 0)
}
fn exists_and_is_sticky_bit(&self) -> bool {
const S_ISVTX: u32 = 0o1000;
let file_mode = try_get_file_mode(self);
file_mode.is_some_and(|mode| mode & S_ISVTX != 0)
}
fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> {
let metadata = self.metadata()?;
Ok((metadata.dev(), metadata.ino()))
}
}
fn try_get_file_type(path: &Path) -> Option<std::fs::FileType> {
path.metadata().map(|metadata| metadata.file_type()).ok()
}
fn try_get_file_mode(path: &Path) -> Option<u32> {
path.metadata().map(|metadata| metadata.mode()).ok()
}
/// Splits a platform-specific PATH-like value into individual paths.
///
/// On Unix, this delegates to [`std::env::split_paths`].
pub fn split_paths<T: AsRef<std::ffi::OsStr> + ?Sized>(s: &T) -> std::env::SplitPaths<'_> {
std::env::split_paths(s)
}
pub(crate) fn get_default_executable_search_paths() -> Vec<PathBuf> {
#[cfg(target_os = "android")]
{
std::env::split_paths(ANDROID_DEFPATH).collect()
}
#[cfg(not(target_os = "android"))]
{
// standard hard-coded defaults for executable search path
vec![
"/usr/local/sbin".into(),
"/usr/local/bin".into(),
"/usr/sbin".into(),
"/usr/bin".into(),
"/sbin".into(),
"/bin".into(),
]
}
}
/// Retrieves the platform-specific set of paths that should contain standard system
/// utilities. Used by `command -p`, for example.
pub fn get_default_standard_utils_paths() -> Vec<PathBuf> {
//
// Try to call confstr(_CS_PATH). If that fails, can't find a string value, or
// finds an empty string, then we'll fall back to hard-coded defaults.
//
if let Ok(Some(cs_path)) = confstr_cs_path()
&& !cs_path.as_os_str().is_empty()
{
return split_paths(&cs_path).collect();
}
#[cfg(target_os = "android")]
{
std::env::split_paths(ANDROID_DEFPATH).collect()
}
#[cfg(not(target_os = "android"))]
{
// standard hard-coded defaults
vec![
"/bin".into(),
"/usr/bin".into(),
"/sbin".into(),
"/usr/sbin".into(),
"/etc".into(),
"/usr/etc".into(),
]
}
}
#[allow(clippy::unnecessary_wraps)]
fn confstr_cs_path() -> Result<Option<PathBuf>, std::io::Error> {
#[cfg(target_os = "android")]
{
Ok(Some(PathBuf::from(ANDROID_DEFPATH)))
}
#[cfg(not(target_os = "android"))]
{
let value = confstr(nix::libc::_CS_PATH)?;
if let Some(value) = value {
let value_str = PathBuf::from(value);
Ok(Some(value_str))
} else {
Ok(None)
}
}
}
/// A wrapper for [`nix::libc::confstr`]. Returns a value for the default PATH variable which
/// indicates where all the POSIX.2 standard utilities can be found.
///
/// N.B. We would strongly prefer to use a safe API exposed (in an idiomatic way) by nix
/// or similar. Until that exists, we accept the need to make the unsafe call directly.
#[cfg(not(target_os = "android"))]
fn confstr(name: nix::libc::c_int) -> Result<Option<std::ffi::OsString>, std::io::Error> {
// SAFETY:
// Calling `confstr` with a null pointer and size 0 is a documented way to query
// the required size of the buffer to hold the value associated with `name`. It
// should not end up causing any undefined behavior.
let required_size = unsafe { nix::libc::confstr(name, std::ptr::null_mut(), 0) };
// When confstr returns 0, it either means there's no value associated with _CS_PATH, or
// _CS_PATH is considered invalid (and not present) on this platform. In both cases, we
// treat it as a non-existent value and return None.
if required_size == 0 {
return Ok(None);
}
let mut buffer = Vec::<u8>::with_capacity(required_size);
// SAFETY:
// We are calling `confstr` with a valid pointer and size that we obtained from the
// allocated buffer. Writing `c_char` (i8 or u8 depending on the platform) into
// `Vec<u8>` is fine, as i8 and u8 have compatible representations, and Rust does
// not support platforms where `c_char` is not 8-bit wide.
let final_size =
unsafe { nix::libc::confstr(name, buffer.as_mut_ptr().cast(), buffer.capacity()) };
if final_size == 0 {
return Err(std::io::Error::last_os_error());
}
// Per the docs on `confstr`, it *may* return a size larger than the provided buffer.
// In our usage we wouldn't expect to see this, as we've first queried the required size.
// However, we defensively check for this case and return an error if it happens.
if final_size > buffer.capacity() {
return Err(std::io::Error::other(
"confstr needed more space than advertised",
));
}
// SAFETY:
// We are trusting `confstr` to have written exactly `final_size` bytes into the buffer.
// We have checked above that it didn't return a value *larger* than the capacity of
// the buffer, and also checked for known error cases. Note that the returned length
// should include the null terminator.
unsafe { buffer.set_len(final_size) };
// The last byte is a null terminator. We assert that it is.
if !matches!(buffer.pop(), Some(0)) {
return Err(std::io::Error::other(
"confstr did not null-terminate the returned string",
));
}
Ok(Some(std::ffi::OsString::from_vec(buffer)))
}
/// Opens a null file that will discard all I/O.
pub fn open_null_file() -> Result<std::fs::File, error::Error> {
let f = std::fs::File::options()
.read(true)
.write(true)
.open("/dev/null")?;
Ok(f)
}
/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`).
pub const fn try_open_special_file(_path: &Path) -> Option<Result<std::fs::File, std::io::Error>> {
None
}
/// Returns the path to the system-wide shell profile script.
pub fn get_system_profile_path() -> Option<&'static Path> {
Some(Path::new("/etc/profile"))
}
/// Returns the path to the system-wide shell rc script.
pub fn get_system_rc_path() -> Option<&'static Path> {
Some(Path::new("/etc/bash.bashrc"))
}
/// Returns true if the string contains a path separator character.
///
/// On Unix, only `/` is considered a path separator.
pub fn contains_path_separator(s: &str) -> bool {
s.contains('/')
}
/// Returns true if the string ends with a path separator character.
///
/// On Unix, only `/` is considered a path separator.
pub fn ends_with_path_separator(s: &str) -> bool {
s.ends_with('/')
}
/// Returns the string with a trailing path separator removed, if present.
///
/// On Unix, only `/` is considered a path separator.
pub fn strip_path_separator_suffix(s: &str) -> &str {
s.strip_suffix('/').unwrap_or(s)
}
/// Returns the platform default for case-insensitive pathname expansion.
///
/// On Unix, filesystems are typically case-sensitive, so this returns `false`.
pub const fn default_case_insensitive_path_expansion() -> bool {
false
}
/// Finds the byte index of the last path separator in the string.
///
/// On Unix, only `/` is considered a path separator.
pub fn rfind_path_separator(s: &str) -> Option<usize> {
s.rfind('/')
}
/// Splits a string on path separator characters, returning an iterator of components.
///
/// On Unix, only `/` is used as a separator.
pub fn split_path_for_pattern(s: &str) -> impl Iterator<Item = &str> {
s.split('/')
}
/// Returns the root path for an absolute pattern, if the first component indicates one.
///
/// On Unix, an empty first component (from splitting a path like `/foo`) indicates
/// an absolute path rooted at `/`.
pub fn pattern_path_root(first_component: &str) -> Option<PathBuf> {
if first_component.is_empty() {
Some(PathBuf::from("/"))
} else {
None
}
}
/// Pushes a component onto a path for pattern expansion.
///
/// On Unix, this delegates directly to `PathBuf::push`.
pub fn push_path_for_pattern(path: &mut std::path::PathBuf, component: &str) {
path.push(component);
}
/// Normalizes path separators for shell output.
///
/// On Unix, this is a no-op since paths already use `/`.
pub const fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> {
std::borrow::Cow::Borrowed(s)
}
/// Resolves an owned path to the actual on-disk executable file, if any.
///
/// On Unix this is a straight passthrough: if the path is executable, the
/// path is returned unchanged (no clone). This keeps `pathsearch::next`
/// allocation-free on the happy path.
///
/// On Windows this function may append a `PATHEXT` extension and return a
/// possibly-different `PathBuf`.
pub fn resolve_executable(path: PathBuf) -> Option<PathBuf> {
use crate::sys::fs::PathExt;
if path.as_path().executable() {
Some(path)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_separator_helpers() {
assert!(contains_path_separator("foo/bar"));
assert!(!contains_path_separator("foobar"));
// Backslashes are not separators on Unix.
assert!(!contains_path_separator(r"foo\bar"));
assert!(ends_with_path_separator("foo/"));
assert!(!ends_with_path_separator("foo"));
assert!(!ends_with_path_separator(r"foo\"));
assert_eq!(strip_path_separator_suffix("foo/"), "foo");
assert_eq!(strip_path_separator_suffix("foo"), "foo");
assert_eq!(strip_path_separator_suffix(r"foo\"), r"foo\");
assert_eq!(rfind_path_separator("a/b/c"), Some(3));
assert_eq!(rfind_path_separator("abc"), None);
}
#[test]
fn split_path_for_pattern_basic() {
let parts: Vec<_> = split_path_for_pattern("a/b/c").collect();
assert_eq!(parts, vec!["a", "b", "c"]);
let parts: Vec<_> = split_path_for_pattern("/a/b").collect();
assert_eq!(parts, vec!["", "a", "b"]);
// Backslashes are not split on Unix.
let parts: Vec<_> = split_path_for_pattern(r"a\b").collect();
assert_eq!(parts, vec![r"a\b"]);
}
#[test]
fn pattern_path_root_absolute() {
assert_eq!(pattern_path_root(""), Some(PathBuf::from("/")));
}
#[test]
fn pattern_path_root_relative() {
assert_eq!(pattern_path_root("foo"), None);
// Drive-letter syntax is not recognized on Unix.
assert_eq!(pattern_path_root("c:"), None);
}
#[test]
fn push_path_for_pattern_appends_child() {
let mut p = PathBuf::from("/home/reuben");
push_path_for_pattern(&mut p, "foo");
assert_eq!(p, PathBuf::from("/home/reuben/foo"));
}
#[test]
fn normalize_path_separators_is_noop() {
use std::borrow::Cow;
assert!(matches!(
normalize_path_separators("/foo/bar"),
Cow::Borrowed("/foo/bar")
));
}
#[test]
fn default_case_insensitive_is_false() {
assert!(!default_case_insensitive_path_expansion());
}
#[test]
fn resolve_executable_returns_input_unchanged() {
// /bin/sh exists and is executable on every supported Unix host.
let path = PathBuf::from("/bin/sh");
let resolved = resolve_executable(path.clone());
assert_eq!(resolved.as_deref(), Some(path.as_path()));
}
#[test]
fn resolve_executable_returns_none_for_nonexistent() {
let path = PathBuf::from("/this/path/should/not/exist/brush-test");
assert!(resolve_executable(path).is_none());
}
#[test]
fn resolve_executable_returns_none_for_non_executable() {
// /etc/hostname (or similar) is a regular file but not executable.
// Use /etc/passwd which is universally present and not executable.
let path = PathBuf::from("/etc/passwd");
assert!(resolve_executable(path).is_none());
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Terminal input utilities
use std::collections::HashMap;
use std::sync::LazyLock;
use terminfo::capability as cap;
use crate::{error, interfaces};
macro_rules! key {
( $terminfo:expr , $our_key:expr, $terminfo_key:ty ) => {{
(
$our_key,
$terminfo
.get::<$terminfo_key>()
.map(|k| k.expand().to_vec()),
)
}};
}
fn build_terminfo_key_map() -> HashMap<Vec<u8>, interfaces::Key> {
let mut map: HashMap<Vec<u8>, interfaces::Key> = HashMap::new();
if let Ok(ti) = terminfo::Database::from_env() {
// Iterate over key capabilities and populate the map
let key_capabilities = [
key!(ti, interfaces::Key::F(1), cap::KeyF1<'_>),
key!(ti, interfaces::Key::F(2), cap::KeyF2<'_>),
key!(ti, interfaces::Key::F(3), cap::KeyF3<'_>),
key!(ti, interfaces::Key::F(4), cap::KeyF4<'_>),
key!(ti, interfaces::Key::F(5), cap::KeyF5<'_>),
key!(ti, interfaces::Key::F(6), cap::KeyF6<'_>),
key!(ti, interfaces::Key::F(7), cap::KeyF7<'_>),
key!(ti, interfaces::Key::F(8), cap::KeyF8<'_>),
key!(ti, interfaces::Key::F(9), cap::KeyF9<'_>),
key!(ti, interfaces::Key::F(10), cap::KeyF10<'_>),
key!(ti, interfaces::Key::F(11), cap::KeyF11<'_>),
key!(ti, interfaces::Key::F(12), cap::KeyF12<'_>),
key!(ti, interfaces::Key::Backspace, cap::KeyBackspace<'_>),
key!(ti, interfaces::Key::Enter, cap::KeyEnter<'_>),
key!(ti, interfaces::Key::Left, cap::KeyLeft<'_>),
key!(ti, interfaces::Key::Right, cap::KeyRight<'_>),
key!(ti, interfaces::Key::Up, cap::KeyUp<'_>),
key!(ti, interfaces::Key::Down, cap::KeyDown<'_>),
key!(ti, interfaces::Key::Home, cap::KeyHome<'_>),
key!(ti, interfaces::Key::End, cap::KeyEnd<'_>),
key!(ti, interfaces::Key::PageUp, cap::KeyPPage<'_>),
key!(ti, interfaces::Key::PageDown, cap::KeyNPage<'_>),
key!(ti, interfaces::Key::BackTab, cap::BackTab<'_>),
// It's not clear if these belong here, because they're not
// strictly "key" capabilities.
key!(ti, interfaces::Key::Up, cap::CursorUp<'_>),
key!(ti, interfaces::Key::Down, cap::CursorDown<'_>),
key!(ti, interfaces::Key::Left, cap::CursorLeft<'_>),
key!(ti, interfaces::Key::Right, cap::CursorRight<'_>),
];
for (key, v) in key_capabilities {
if let Some(Ok(v)) = v {
map.insert(v.clone(), key.clone());
}
}
}
map
}
pub(crate) static TERMINFO_KEY_MAP: LazyLock<HashMap<Vec<u8>, interfaces::Key>> =
LazyLock::new(build_terminfo_key_map);
/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None`
/// if the key code is not recognized.
///
/// # Arguments
///
/// * `key_code`: The byte sequence representing the key code.
pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option<interfaces::Key> {
if let Some(key) = TERMINFO_KEY_MAP.get(key_code) {
Some(key.clone())
} else if key_code.len() == 1 && !key_code[0].is_ascii_control() {
Some(interfaces::Key::Character(key_code[0] as char))
} else {
None
}
}
+3
View File
@@ -0,0 +1,3 @@
pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
crate::sys::hostname::get()
}
+108
View File
@@ -0,0 +1,108 @@
//! File descriptor polling utilities for timeout support.
use std::os::fd::BorrowedFd;
use std::time::{Duration, Instant};
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
use crate::openfiles::OpenFile;
/// Polls an open file for input readability with a timeout.
///
/// Returns `Ok(true)` if data is available for reading, `Ok(false)` if the timeout
/// elapsed without data becoming available.
///
/// For regular files, always returns `Ok(true)` immediately since they're always
/// "ready" (matching bash behavior where `-t` has no effect on regular files).
///
/// # Arguments
///
/// * `file` - The open file to poll.
/// * `timeout` - Maximum time to wait. Use `Duration::ZERO` to check without blocking.
///
/// # Errors
///
/// Returns an error if polling fails or the file descriptor cannot be borrowed.
pub fn poll_for_input(file: &OpenFile, timeout: Duration) -> std::io::Result<bool> {
let fd = file
.try_borrow_as_fd()
.map_err(|e| std::io::Error::other(e.to_string()))?;
// Regular files are always ready - timeout has no effect (bash behavior).
if is_regular_file(fd) {
return Ok(true);
}
// Convert timeout to deadline for accurate time tracking across EINTR retries.
let deadline = if timeout.is_zero() {
// For zero timeout, use current instant so first check sees zero remaining.
Some(Instant::now())
} else {
Some(Instant::now() + timeout)
};
poll_fd_for_input(fd, deadline)
}
/// Polls a file descriptor for input readability with a deadline.
///
/// Returns `Ok(true)` if data is available, `Ok(false)` if deadline passed.
///
/// # Arguments
///
/// * `fd` - File descriptor to poll
/// * `deadline` - Optional deadline; `None` indicates no deadline.
fn poll_fd_for_input(fd: BorrowedFd<'_>, deadline: Option<Instant>) -> std::io::Result<bool> {
let mut poll_fds = [PollFd::new(fd, PollFlags::POLLIN)];
let mut first_iteration = true;
loop {
// Calculate remaining time on each iteration to handle EINTR correctly.
let timeout_ms = match deadline {
Some(d) => {
let remaining = d.saturating_duration_since(Instant::now());
// On first iteration, always do at least one poll even with zero timeout.
// This allows `-t 0` to check if input is immediately available.
if remaining.is_zero() && !first_iteration {
return Ok(false); // Deadline passed after initial poll.
}
i32::try_from(remaining.as_millis()).unwrap_or(i32::MAX)
}
None => -1, // Block indefinitely.
};
first_iteration = false;
let poll_timeout = PollTimeout::try_from(timeout_ms).unwrap_or(PollTimeout::MAX);
match poll(&mut poll_fds, poll_timeout) {
Ok(0) => return Ok(false), // Timeout
Ok(_) => {
let revents = poll_fds[0].revents().unwrap_or(PollFlags::empty());
// POLLIN means data available. POLLHUP/POLLERR without POLLIN means
// EOF/error - return true so caller reads and gets the proper result.
return Ok(
revents.intersects(PollFlags::POLLIN | PollFlags::POLLHUP | PollFlags::POLLERR)
);
}
Err(nix::errno::Errno::EINTR) => (), // Retry on signal with recalculated timeout.
Err(e) => return Err(std::io::Error::from_raw_os_error(e as i32)),
}
}
}
/// Checks if a file descriptor refers to a regular file.
///
/// Regular files are always "ready" for reading (poll has no effect).
///
/// # Arguments
///
/// * `fd` - File descriptor to check
fn is_regular_file(fd: BorrowedFd<'_>) -> bool {
match nix::sys::stat::fstat(fd) {
Ok(stat) => {
use nix::sys::stat::{SFlag, mode_t};
mode_t::try_from(stat.st_mode)
.is_ok_and(|mode| SFlag::from_bits_truncate(mode).contains(SFlag::S_IFREG))
}
Err(_) => false,
}
}
+31
View File
@@ -0,0 +1,31 @@
//! Resource utilities
use crate::error;
/// Returns the user and system CPU time used by the current process;
/// expressed as a tuple containing user time and system time, in that order.
pub fn get_self_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_SELF)?;
Ok((
convert_rusage_time(usage.user_time()),
convert_rusage_time(usage.system_time()),
))
}
/// Returns the user and system CPU time used by child processes; expressed
/// as a tuple containing user time and system time, in that order.
pub fn get_children_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_CHILDREN)?;
Ok((
convert_rusage_time(usage.user_time()),
convert_rusage_time(usage.system_time()),
))
}
const fn convert_rusage_time(time: nix::sys::time::TimeVal) -> std::time::Duration {
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::cast_possible_truncation)]
std::time::Duration::new(time.tv_sec() as u64, time.tv_usec() as u32 * 1000)
}
+160
View File
@@ -0,0 +1,160 @@
//! Signal processing utilities
use crate::{error, sys, traps};
pub(crate) use nix::sys::signal::Signal;
pub(crate) fn continue_process(pid: sys::process::ProcessId) -> Result<(), error::Error> {
nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), nix::sys::signal::SIGCONT)
.map_err(|_errno| error::ErrorKind::FailedToSendSignal)?;
Ok(())
}
/// Sends a signal to a specific process.
///
/// # Arguments
/// * `pid` - The process ID to send the signal to
/// * `signal` - The signal to send (must be a real signal, not a trap signal)
pub fn kill_process(
pid: sys::process::ProcessId,
signal: traps::TrapSignal,
) -> Result<(), error::Error> {
let translated_signal = match signal {
traps::TrapSignal::Signal(signal) => signal,
traps::TrapSignal::Debug
| traps::TrapSignal::Err
| traps::TrapSignal::Exit
| traps::TrapSignal::Return => {
return Err(error::ErrorKind::InvalidSignal(signal.to_string()).into());
}
};
nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), translated_signal)
.map_err(|_errno| error::ErrorKind::FailedToSendSignal)?;
Ok(())
}
pub(crate) fn lead_new_process_group() -> Result<(), error::Error> {
nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))?;
Ok(())
}
pub(crate) fn tstp_signal_listener() -> Result<tokio::signal::unix::Signal, error::Error> {
let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(
nix::libc::SIGTSTP,
))?;
Ok(signal)
}
pub(crate) fn chld_signal_listener() -> Result<tokio::signal::unix::Signal, error::Error> {
let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::child())?;
Ok(signal)
}
pub(crate) use tokio::signal::ctrl_c as await_ctrl_c;
pub(crate) fn mask_sigttou() -> Result<(), error::Error> {
let ignore = nix::sys::signal::SigAction::new(
nix::sys::signal::SigHandler::SigIgn,
nix::sys::signal::SaFlags::empty(),
nix::sys::signal::SigSet::empty(),
);
// SAFETY:
// Setting the signal action should be safe here. The unsafe concerns
// for calling `sigaction` are primarily around ensuring that any provided
// signal handler functions are only performing operations that are
// safe to do in a signal handler context. Here we are not providing
// a custom handler, just asking the OS to ignore the signal.
unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGTTOU, &ignore) }?;
Ok(())
}
pub(crate) fn poll_for_stopped_children() -> Result<bool, error::Error> {
let mut found_stopped = false;
loop {
let wait_status = waitid_all(
nix::sys::wait::WaitPidFlag::WUNTRACED | nix::sys::wait::WaitPidFlag::WNOHANG,
);
match wait_status {
Ok(nix::sys::wait::WaitStatus::Stopped(_stopped_pid, _signal)) => {
found_stopped = true;
}
Ok(_) => break,
Err(nix::errno::Errno::ECHILD) => break,
Err(e) => return Err(e.into()),
}
}
Ok(found_stopped)
}
#[cfg(not(target_os = "macos"))]
fn waitid_all(
flags: nix::sys::wait::WaitPidFlag,
) -> Result<nix::sys::wait::WaitStatus, nix::errno::Errno> {
nix::sys::wait::waitid(nix::sys::wait::Id::All, flags)
}
//
// N.B. These functions were mostly copied from nix::sys::wait (https://github.com/nix-rust/nix, MIT license)
// to enable use of the `waitid` call on macOS. Ideally nix would expose it on macOS and we would
// remove this code.
//
#[cfg(target_os = "macos")]
fn waitid_all(
flags: nix::sys::wait::WaitPidFlag,
) -> Result<nix::sys::wait::WaitStatus, nix::errno::Errno> {
// SAFETY:
// Code copied from nix::sys::wait implementation of waitid for other platforms.
// The siginfo structure is valid when filled with zeroes. Memory is zeroed
// rather than uninitialized, as not all platforms initialize the memory in
// the StillAlive case.
let mut siginfo: nix::libc::siginfo_t = unsafe { std::mem::zeroed() };
// SAFETY:
// Code copied from nix::sys::wait implementation of waitid for other platforms.
nix::errno::Errno::result(unsafe {
nix::libc::waitid(nix::libc::P_ALL, 0, &raw mut siginfo, flags.bits())
})?;
siginfo_to_wait_status(siginfo)
}
#[cfg(target_os = "macos")]
fn siginfo_to_wait_status(
siginfo: nix::libc::siginfo_t,
) -> Result<nix::sys::wait::WaitStatus, nix::errno::Errno> {
// SAFETY:
// Code copied from nix::sys::wait implementation of waitid for other platforms.
let si_pid = unsafe { siginfo.si_pid() };
if si_pid == 0 {
return Ok(nix::sys::wait::WaitStatus::StillAlive);
}
let pid = nix::unistd::Pid::from_raw(si_pid);
// SAFETY:
// Code copied from nix::sys::wait implementation of waitid for other platforms.
let si_status = unsafe { siginfo.si_status() };
let status = match siginfo.si_code {
nix::libc::CLD_EXITED => nix::sys::wait::WaitStatus::Exited(pid, si_status),
nix::libc::CLD_KILLED | nix::libc::CLD_DUMPED => nix::sys::wait::WaitStatus::Signaled(
pid,
nix::sys::signal::Signal::try_from(si_status)?,
siginfo.si_code == nix::libc::CLD_DUMPED,
),
nix::libc::CLD_STOPPED => {
nix::sys::wait::WaitStatus::Stopped(pid, nix::sys::signal::Signal::try_from(si_status)?)
}
nix::libc::CLD_CONTINUED => nix::sys::wait::WaitStatus::Continued(pid),
_ => return Err(nix::errno::Errno::EINVAL),
};
Ok(status)
}
+119
View File
@@ -0,0 +1,119 @@
//! Terminal utilities.
use crate::{error, openfiles, sys, terminal};
use std::{io::IsTerminal, os::fd::AsFd, path::PathBuf};
/// Terminal configuration.
#[derive(Clone, Debug)]
pub struct Config {
termios: nix::sys::termios::Termios,
}
impl Config {
/// Creates a new `Config` from the actual terminal attributes of the terminal associated
/// with the given file descriptor.
///
/// # Arguments
///
/// * `file` - A reference to the open terminal.
pub fn from_term(file: &openfiles::OpenFile) -> Result<Self, error::Error> {
let fd = file.try_borrow_as_fd()?;
let termios = nix::sys::termios::tcgetattr(fd)?;
Ok(Self { termios })
}
/// Applies the terminal settings to the terminal associated with the given file descriptor.
///
/// # Arguments
///
/// * `file` - A reference to the open terminal.
pub fn apply_to_term(&self, file: &openfiles::OpenFile) -> Result<(), error::Error> {
let fd = file.try_borrow_as_fd()?;
nix::sys::termios::tcsetattr(fd, nix::sys::termios::SetArg::TCSANOW, &self.termios)?;
Ok(())
}
/// Applies the given high-level terminal settings to this configuration. Does not modify any
/// terminal itself.
///
/// # Arguments
///
/// * `settings` - The high-level terminal settings to apply to this configuration.
pub fn update(&mut self, settings: &terminal::Settings) {
if let Some(echo_input) = &settings.echo_input {
if *echo_input {
self.termios.local_flags |= nix::sys::termios::LocalFlags::ECHO;
} else {
self.termios.local_flags -= nix::sys::termios::LocalFlags::ECHO;
}
}
if let Some(line_input) = &settings.line_input {
if *line_input {
self.termios.local_flags |= nix::sys::termios::LocalFlags::ICANON;
} else {
self.termios.local_flags -= nix::sys::termios::LocalFlags::ICANON;
}
}
if let Some(interrupt_signals) = &settings.interrupt_signals {
if *interrupt_signals {
self.termios.local_flags |= nix::sys::termios::LocalFlags::ISIG;
} else {
self.termios.local_flags -= nix::sys::termios::LocalFlags::ISIG;
}
}
if let Some(output_nl_as_nlcr) = &settings.output_nl_as_nlcr {
if *output_nl_as_nlcr {
self.termios.output_flags |=
nix::sys::termios::OutputFlags::OPOST | nix::sys::termios::OutputFlags::ONLCR;
} else {
self.termios.output_flags -= nix::sys::termios::OutputFlags::ONLCR;
}
}
}
}
/// Get the process ID of this process's parent.
pub fn get_parent_process_id() -> Option<sys::process::ProcessId> {
Some(nix::unistd::getppid().as_raw())
}
/// Get the process group ID for this process's process group.
pub fn get_process_group_id() -> Option<sys::process::ProcessId> {
Some(nix::unistd::getpgrp().as_raw())
}
/// Get the foreground process ID of the attached terminal.
pub fn get_foreground_pid() -> Option<sys::process::ProcessId> {
nix::unistd::tcgetpgrp(std::io::stdin())
.ok()
.map(|pgid| pgid.as_raw())
}
/// Move the specified process to the foreground of the attached terminal.
pub fn move_to_foreground(pid: sys::process::ProcessId) -> Result<(), error::Error> {
nix::unistd::tcsetpgrp(std::io::stdin(), nix::unistd::Pid::from_raw(pid))?;
Ok(())
}
/// Moves the current process to the foreground of the attached terminal.
// This function needs to return `std::io::Error` so that the OS error code can be recovered.
pub fn move_self_to_foreground() -> Result<(), std::io::Error> {
if std::io::stdin().is_terminal() {
let pgid = nix::unistd::getpgid(None)?;
// TODO(jobs): This sometimes fails with ENOTTY even though we checked that stdin is a
// terminal. We should investigate why this is happening.
let _ = nix::unistd::tcsetpgrp(std::io::stdin(), pgid);
}
Ok(())
}
/// Tries to get the path of the terminal device associated with the attached terminal.
/// Returns `None` if there is no terminal attached or the lookup failed.
pub fn try_get_terminal_device_path() -> Option<PathBuf> {
nix::unistd::ttyname(std::io::stdin()).ok()
}
+90
View File
@@ -0,0 +1,90 @@
use crate::{error, trace_categories};
use std::path::PathBuf;
use uzers::os::unix::UserExt;
pub(crate) fn is_root() -> bool {
uzers::get_current_uid() == 0
}
pub(crate) fn get_user_home_dir(username: &str) -> Option<PathBuf> {
if let Some(user_info) = uzers::get_user_by_name(username) {
return Some(user_info.home_dir().to_path_buf());
}
None
}
pub(crate) fn get_current_user_home_dir() -> Option<PathBuf> {
if let Some(username) = uzers::get_current_username()
&& let Some(user_info) = uzers::get_user_by_name(&username)
{
return Some(user_info.home_dir().to_path_buf());
}
None
}
pub(crate) fn get_current_user_default_shell() -> Option<PathBuf> {
if let Some(username) = uzers::get_current_username()
&& let Some(user_info) = uzers::get_user_by_name(&username)
{
return Some(user_info.shell().to_path_buf());
}
None
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_current_uid() -> Result<u32, error::Error> {
Ok(uzers::get_current_uid())
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_current_gid() -> Result<u32, error::Error> {
Ok(uzers::get_current_gid())
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
Ok(uzers::get_effective_uid())
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
Ok(uzers::get_effective_gid())
}
pub(crate) fn get_current_username() -> Result<String, error::Error> {
let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?;
Ok(username.to_string_lossy().to_string())
}
pub(crate) fn get_user_group_ids() -> Result<Vec<u32>, error::Error> {
let groups = get_current_user_groups()?;
Ok(groups.into_iter().map(|g| g.gid()).collect())
}
pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
// TODO(#475): uzers::all_users() is available but unsafe; for now we just return the current
// user. That's better than nothing.
let user = get_current_username()?;
Ok(vec![user])
}
pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
// TODO(#475): uzers::all_groups() is available but unsafe; for now we just return the current
// user's groups. That's better than nothing.
let groups = get_current_user_groups()?;
let group_names = groups
.into_iter()
.map(|g| g.name().to_string_lossy().to_string());
Ok(group_names.collect())
}
fn get_current_user_groups() -> Result<Vec<uzers::Group>, error::Error> {
let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?;
let gid = uzers::get_current_gid();
let groups = uzers::get_user_groups(&username, gid).unwrap_or_default();
Ok(groups)
}
+65
View File
@@ -0,0 +1,65 @@
//! Filesystem utilities for WASM.
pub use crate::sys::stubs::fs::*;
impl crate::sys::fs::PathExt for std::path::Path {
fn readable(&self) -> bool {
true
}
fn writable(&self) -> bool {
true
}
fn executable(&self) -> bool {
true
}
fn exists_and_is_block_device(&self) -> bool {
false
}
fn exists_and_is_char_device(&self) -> bool {
false
}
fn exists_and_is_fifo(&self) -> bool {
false
}
fn exists_and_is_socket(&self) -> bool {
false
}
fn exists_and_is_setgid(&self) -> bool {
false
}
fn exists_and_is_setuid(&self) -> bool {
false
}
fn exists_and_is_sticky_bit(&self) -> bool {
false
}
fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> {
Ok((0, 0))
}
}
/// Splits a PATH-like value into individual paths.
///
/// On WASM, `std::env::split_paths` is not available, so this
/// implementation splits by the `:` separator.
pub fn split_paths<T: AsRef<std::ffi::OsStr> + ?Sized>(
s: &T,
) -> impl Iterator<Item = std::path::PathBuf> {
s.as_ref()
.to_str()
.unwrap_or_default()
.split(':')
.map(std::path::PathBuf::from)
.collect::<Vec<_>>()
.into_iter()
}
+18
View File
@@ -0,0 +1,18 @@
pub use crate::sys::stubs::async_pipe;
pub use crate::sys::stubs::commands;
pub(crate) use crate::sys::stubs::env;
pub use crate::sys::stubs::fd;
pub(crate) mod fs;
pub use crate::sys::stubs::input;
pub(crate) use crate::sys::stubs::network;
pub(crate) use crate::sys::stubs::pipes;
pub use crate::sys::stubs::poll;
pub use crate::sys::stubs::process;
pub use crate::sys::stubs::resource;
pub use crate::sys::stubs::signal;
pub use crate::sys::stubs::terminal;
pub(crate) use crate::sys::stubs::users;
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
pub enum PlatformError {}
+23
View File
@@ -0,0 +1,23 @@
pub use crate::sys::stubs::async_pipe;
pub use crate::sys::stubs::commands;
pub(crate) mod env;
pub use crate::sys::stubs::fd;
pub(crate) mod fs;
pub use crate::sys::stubs::input;
pub(crate) mod network;
pub use crate::sys::stubs::poll;
pub use crate::sys::stubs::resource;
/// Signal processing utilities
pub mod signal {
pub(crate) use crate::sys::stubs::signal::*;
pub(crate) use tokio::signal::ctrl_c as await_ctrl_c;
}
pub use crate::sys::stubs::terminal;
pub use crate::sys::tokio_process as process;
pub(crate) mod users;
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
pub enum PlatformError {}
+207
View File
@@ -0,0 +1,207 @@
//! Environment variable retrieval for Windows.
//!
//! On Windows, well-known environment variable names are normalized to their
//! canonical POSIX forms (e.g. `Path` → `PATH`), and `HOME` is synthesized
//! from `USERPROFILE` or `HOMEDRIVE`+`HOMEPATH` if not already present.
use std::collections::BTreeMap;
/// Retrieves environment variables from the host process, applying
/// Windows-specific fixups.
///
/// Normalizes well-known variable names to POSIX conventions, copies
/// `TEMP`/`TMP` to `TMPDIR` (preserving originals for native Windows apps),
/// and synthesizes `HOME` if it is not natively defined.
///
/// A [`BTreeMap`] is used (rather than `HashMap`) so iteration order is
/// deterministic across runs. If two source variables collide under the same
/// canonical name (e.g. both `Path` and `PATH` are set to different values),
/// the conflict is logged and the last-seen value wins.
pub(crate) fn get_host_env_vars() -> impl Iterator<Item = (String, String)> {
collect_host_env_vars(std::env::vars())
}
/// Collects and normalizes a set of environment variables. Exposed as a
/// pure function (taking the source iterator) so it can be unit-tested
/// without touching the process environment.
fn collect_host_env_vars<I>(source: I) -> std::collections::btree_map::IntoIter<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
let mut vars = BTreeMap::new();
// Normalize host env vars and inject them into the map.
for (k, v) in source {
let normalized = normalize_env_name(&k);
if let Some(existing) = vars.get(&normalized)
&& existing != &v
{
tracing::warn!(
"environment variable collision under canonical name {normalized}: \
two different values were supplied (last-write wins)"
);
}
vars.insert(normalized, v);
}
// Synthesize HOME from Windows-native variables if not already present.
if !vars.contains_key("HOME") {
let home = vars.get("USERPROFILE").cloned().or_else(|| {
let d = vars.get("HOMEDRIVE")?;
let p = vars.get("HOMEPATH")?;
Some(format!("{d}{p}"))
});
if let Some(home) = home {
vars.insert("HOME".to_string(), home);
}
}
// Copy TEMP/TMP to TMPDIR if TMPDIR doesn't already exist.
if !vars.contains_key("TMPDIR")
&& let Some(tmp) = vars.get("TEMP").or_else(|| vars.get("TMP")).cloned()
{
vars.insert("TMPDIR".to_string(), tmp);
}
vars.into_iter()
}
/// Normalizes the case of well-known environment variable names to their
/// canonical POSIX forms (`Path` → `PATH`, `home` → `HOME`).
///
/// # Arguments
///
/// * `name` - The environment variable name to normalize.
fn normalize_env_name(name: &str) -> String {
// Normalize well-known variable names so that later lookups by
// canonical (uppercase) spelling always succeed regardless of the
// host's original casing.
const WELL_KNOWN: &[&str] = &[
"PATH",
"HOME",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
"TEMP",
"TMP",
"TMPDIR",
];
for &canonical in WELL_KNOWN {
if name.eq_ignore_ascii_case(canonical) {
return canonical.to_string();
}
}
name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn run(source: &[(&str, &str)]) -> BTreeMap<String, String> {
collect_host_env_vars(
source
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string())),
)
.collect()
}
#[test]
fn normalize_env_name_canonicalizes_well_known() {
assert_eq!(normalize_env_name("Path"), "PATH");
assert_eq!(normalize_env_name("path"), "PATH");
assert_eq!(normalize_env_name("PATH"), "PATH");
assert_eq!(normalize_env_name("Home"), "HOME");
assert_eq!(normalize_env_name("UserProfile"), "USERPROFILE");
assert_eq!(normalize_env_name("Temp"), "TEMP");
assert_eq!(normalize_env_name("Tmp"), "TMP");
assert_eq!(normalize_env_name("TmpDir"), "TMPDIR");
}
#[test]
fn normalize_env_name_leaves_unknown_alone() {
assert_eq!(normalize_env_name("FOO"), "FOO");
assert_eq!(normalize_env_name("myVar"), "myVar");
// Does not uppercase unknown names.
assert_eq!(normalize_env_name("AppData"), "AppData");
}
#[test]
fn synthesizes_home_from_userprofile() {
let vars = run(&[("UserProfile", r"C:\Users\reuben")]);
assert_eq!(
vars.get("HOME").map(String::as_str),
Some(r"C:\Users\reuben")
);
assert_eq!(
vars.get("USERPROFILE").map(String::as_str),
Some(r"C:\Users\reuben")
);
}
#[test]
fn synthesizes_home_from_homedrive_homepath_when_no_userprofile() {
let vars = run(&[("HomeDrive", "C:"), ("HomePath", r"\Users\reuben")]);
assert_eq!(
vars.get("HOME").map(String::as_str),
Some(r"C:\Users\reuben")
);
}
#[test]
fn preserves_existing_home() {
let vars = run(&[("HOME", "/already/set"), ("UserProfile", r"C:\Users\other")]);
assert_eq!(vars.get("HOME").map(String::as_str), Some("/already/set"));
}
#[test]
fn copies_temp_to_tmpdir() {
let vars = run(&[("Temp", r"C:\Windows\Temp")]);
assert_eq!(
vars.get("TMPDIR").map(String::as_str),
Some(r"C:\Windows\Temp")
);
}
#[test]
fn prefers_temp_over_tmp_for_tmpdir() {
let vars = run(&[("TEMP", "one"), ("TMP", "two")]);
assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("one"));
}
#[test]
fn falls_back_to_tmp_when_no_temp() {
let vars = run(&[("TMP", "two")]);
assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("two"));
}
#[test]
fn preserves_existing_tmpdir() {
let vars = run(&[("TMPDIR", "original"), ("TEMP", "other")]);
assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("original"));
}
#[test]
fn deterministic_iteration_order() {
// BTreeMap iteration order is determined by the sorted key order,
// so the outputs must be identical across invocations regardless of
// input ordering.
let a = run(&[("Path", "first"), ("ZETA", "zz"), ("Alpha", "aa")]);
let b = run(&[("ZETA", "zz"), ("Alpha", "aa"), ("Path", "first")]);
let keys_a: Vec<_> = a.keys().collect();
let keys_b: Vec<_> = b.keys().collect();
assert_eq!(keys_a, keys_b);
}
#[test]
fn collision_last_write_wins() {
// When two source names normalize to the same canonical key,
// the later one should overwrite the earlier one (and it should not
// panic).
let vars = run(&[("Path", "first"), ("PATH", "second")]);
assert_eq!(vars.get("PATH").map(String::as_str), Some("second"));
}
}
+455
View File
@@ -0,0 +1,455 @@
//! Filesystem utilities for Windows.
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use crate::error;
// Selectively re-export items from stubs that we don't override.
pub(crate) use crate::sys::stubs::fs::MetadataExt;
/// Cached list of executable extensions from the `PATHEXT` environment
/// variable. Each entry retains its leading dot (e.g. `".exe"`) and is stored
/// lowercased so case-insensitive comparisons can be done without allocating.
///
/// NOTE: This is cached for the process lifetime. Changes to `PATHEXT` made
/// inside the running shell are not reflected here. Bash itself has no
/// `PATHEXT` semantics, so this is generally acceptable for now.
static PATHEXT_EXTENSIONS: LazyLock<Vec<String>> = LazyLock::new(|| {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
.collect()
});
/// Returns the stem of a PATHEXT entry (with any leading `.` removed).
///
/// `PATHEXT` canonically stores entries like `.EXE`, but tolerant parsing
/// accepts entries without the leading dot too.
fn pathext_entry_stem(entry: &str) -> &str {
entry.strip_prefix('.').unwrap_or(entry)
}
/// Returns true if the path's extension is in the PATHEXT list.
///
/// Performs case-insensitive comparison against the cached PATHEXT entries
/// without allocating.
fn has_executable_extension(path: &Path) -> bool {
path.extension().is_some_and(|ext| {
PATHEXT_EXTENSIONS
.iter()
.any(|e| ext.eq_ignore_ascii_case(pathext_entry_stem(e)))
})
}
/// Returns true if `path` is, by itself, an existing executable file.
///
/// Used both for the initial check in [`resolve_executable`] and for
/// [`PathExt::executable`].
fn is_executable_file(path: &Path) -> bool {
has_executable_extension(path) && path.is_file()
}
/// Resolves an owned path to the actual on-disk executable file, if any.
///
/// If the path is already a file with a `PATHEXT` extension, it is returned
/// unchanged (no allocation). Otherwise, each `PATHEXT` extension is appended
/// in turn and the first existing file is returned.
pub fn resolve_executable(path: PathBuf) -> Option<PathBuf> {
if is_executable_file(&path) {
return Some(path);
}
// Try appending each PATHEXT extension.
for ext in PATHEXT_EXTENSIONS.iter() {
let mut name = path.as_os_str().to_owned();
name.push(ext);
let candidate = PathBuf::from(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
impl crate::sys::fs::PathExt for Path {
fn readable(&self) -> bool {
self.exists()
}
fn writable(&self) -> bool {
self.metadata().is_ok_and(|m| !m.permissions().readonly())
}
fn executable(&self) -> bool {
if is_executable_file(self) {
return true;
}
// Try each PATHEXT extension without allocating a separate PathBuf
// per candidate until one exists.
PATHEXT_EXTENSIONS.iter().any(|ext| {
let mut name = self.as_os_str().to_owned();
name.push(ext);
Self::new(&name).is_file()
})
}
fn exists_and_is_block_device(&self) -> bool {
false
}
fn exists_and_is_char_device(&self) -> bool {
false
}
fn exists_and_is_fifo(&self) -> bool {
false
}
fn exists_and_is_socket(&self) -> bool {
false
}
fn exists_and_is_setgid(&self) -> bool {
false
}
fn exists_and_is_setuid(&self) -> bool {
false
}
fn exists_and_is_sticky_bit(&self) -> bool {
false
}
fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> {
// TODO(windows): implement using file index / volume serial number.
Err(error::ErrorKind::NotSupportedOnThisPlatform("get_device_and_inode").into())
}
}
/// Splits a platform-specific PATH-like value into individual paths.
///
/// On Windows, this delegates to [`std::env::split_paths`].
pub fn split_paths<T: AsRef<OsStr> + ?Sized>(s: &T) -> std::env::SplitPaths<'_> {
std::env::split_paths(s)
}
/// Opens a null file that will discard all I/O.
pub fn open_null_file() -> Result<std::fs::File, error::Error> {
let f = std::fs::File::options()
.read(true)
.write(true)
.open("NUL")?;
Ok(f)
}
/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`).
pub fn try_open_special_file(path: &Path) -> Option<Result<std::fs::File, std::io::Error>> {
if path.ends_with("dev/null") && path.is_absolute() {
Some(open_null_file().map_err(std::io::Error::other))
} else {
None
}
}
/// Returns the default paths where executables are typically found on Windows.
pub(crate) fn get_default_executable_search_paths() -> Vec<PathBuf> {
default_system_paths()
}
/// Returns the default paths where standard system utilities are found on Windows.
pub fn get_default_standard_utils_paths() -> Vec<PathBuf> {
default_system_paths()
}
fn default_system_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(sysroot) = std::env::var("SystemRoot") {
paths.push(PathBuf::from(&sysroot).join("system32"));
paths.push(PathBuf::from(&sysroot));
paths.push(PathBuf::from(&sysroot).join("System32").join("Wbem"));
paths.push(
PathBuf::from(&sysroot)
.join("System32")
.join("WindowsPowerShell")
.join("v1.0"),
);
}
if let Ok(userprofile) = std::env::var("USERPROFILE") {
paths.push(
PathBuf::from(userprofile)
.join("AppData")
.join("Local")
.join("Microsoft")
.join("WindowsApps"),
);
}
paths
}
/// Returns the path to the system-wide shell profile script.
///
/// On Windows, no system profile is loaded by default.
pub const fn get_system_profile_path() -> Option<&'static Path> {
None
}
/// Returns the path to the system-wide shell rc script.
///
/// On Windows, no system rc file is loaded by default.
pub const fn get_system_rc_path() -> Option<&'static Path> {
None
}
/// Returns the platform default for case-insensitive pathname expansion.
///
/// On Windows, filesystems are typically case-insensitive, so this returns `true`.
pub const fn default_case_insensitive_path_expansion() -> bool {
true
}
/// Path separator characters on Windows.
const PATH_SEPARATORS: [char; 2] = ['/', '\\'];
/// Returns true if the string contains a path separator character.
///
/// On Windows, both `/` and `\` are considered path separators.
pub fn contains_path_separator(s: &str) -> bool {
s.contains(PATH_SEPARATORS)
}
/// Returns true if the string ends with a path separator character.
///
/// On Windows, both `/` and `\` are considered path separators.
pub fn ends_with_path_separator(s: &str) -> bool {
s.ends_with(PATH_SEPARATORS)
}
/// Returns the string with a trailing path separator removed, if present.
///
/// On Windows, both `/` and `\` are considered path separators.
pub fn strip_path_separator_suffix(s: &str) -> &str {
s.strip_suffix(PATH_SEPARATORS).unwrap_or(s)
}
/// Finds the byte index of the last path separator in the string.
///
/// On Windows, both `/` and `\` are considered path separators.
pub fn rfind_path_separator(s: &str) -> Option<usize> {
s.rfind(PATH_SEPARATORS)
}
/// Splits a string on path separator characters, returning an iterator of components.
///
/// On Windows, both `/` and `\` are used as separators.
pub fn split_path_for_pattern(s: &str) -> impl Iterator<Item = &str> {
s.split(PATH_SEPARATORS)
}
/// Returns the root path for an absolute pattern, if the first component indicates one.
///
/// On Windows, recognizes both a leading separator (empty first component from splitting
/// a path like `/foo`) and a drive-letter prefix like `C:` as absolute.
///
/// TODO(windows): UNC paths like `\\server\share\foo` are not yet handled
/// specially; they split into `["", "", "server", "share", "foo"]`, and the
/// leading empty component causes them to be treated as if they were rooted
/// at `/`, which drops the server/share portion. Supporting UNC requires
/// peeking further into the component list.
pub fn pattern_path_root(first_component: &str) -> Option<PathBuf> {
if first_component.is_empty() {
// Leading separator, e.g. `/foo` split into ["", "foo"].
Some(PathBuf::from("/"))
} else if first_component.len() == 2
&& first_component.as_bytes()[0].is_ascii_alphabetic()
&& first_component.as_bytes()[1] == b':'
{
// Drive letter prefix, e.g. `c:/foo` split into ["c:", "foo"].
let mut root = String::with_capacity(3);
root.push_str(first_component);
root.push('/');
Some(PathBuf::from(root))
} else {
None
}
}
/// Pushes a component onto a path for pattern expansion.
///
/// On Windows, `PathBuf::push` has special drive-letter and root-replacement
/// semantics that conflict with shell path construction (e.g. pushing `C:foo`
/// onto `D:\bar` replaces the whole path). This function always appends the
/// component as a child, operating on the underlying `OsString` so non-UTF-8
/// content in the path is preserved and no reallocation is needed.
pub fn push_path_for_pattern(path: &mut PathBuf, component: &str) {
// Separator characters are ASCII, and WTF-8-encoded OsStr bytes are a
// superset of UTF-8, so checking the last byte directly is safe.
let bytes = path.as_os_str().as_encoded_bytes();
let needs_sep = !bytes.is_empty() && !matches!(bytes.last(), Some(b'/' | b'\\'));
let buf = path.as_mut_os_string();
if needs_sep {
buf.push("/");
}
buf.push(component);
}
/// Normalizes path separators for shell output.
///
/// On Windows, replaces `\` with `/` since backslash is the shell escape character.
pub fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> {
if s.contains('\\') {
std::borrow::Cow::Owned(s.replace('\\', "/"))
} else {
std::borrow::Cow::Borrowed(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_separator_helpers_both_slashes() {
assert!(contains_path_separator("foo/bar"));
assert!(contains_path_separator(r"foo\bar"));
assert!(contains_path_separator(r"mixed/and\back"));
assert!(!contains_path_separator("foobar"));
assert!(ends_with_path_separator("foo/"));
assert!(ends_with_path_separator(r"foo\"));
assert!(!ends_with_path_separator("foo"));
assert_eq!(strip_path_separator_suffix("foo/"), "foo");
assert_eq!(strip_path_separator_suffix(r"foo\"), "foo");
assert_eq!(strip_path_separator_suffix("foo"), "foo");
assert_eq!(rfind_path_separator("a/b/c"), Some(3));
assert_eq!(rfind_path_separator(r"a\b\c"), Some(3));
assert_eq!(rfind_path_separator(r"a/b\c"), Some(3));
assert_eq!(rfind_path_separator("abc"), None);
}
#[test]
fn split_path_for_pattern_both_slashes() {
let parts: Vec<_> = split_path_for_pattern("a/b/c").collect();
assert_eq!(parts, vec!["a", "b", "c"]);
let parts: Vec<_> = split_path_for_pattern(r"a\b\c").collect();
assert_eq!(parts, vec!["a", "b", "c"]);
let parts: Vec<_> = split_path_for_pattern(r"a/b\c").collect();
assert_eq!(parts, vec!["a", "b", "c"]);
let parts: Vec<_> = split_path_for_pattern("/a/b").collect();
assert_eq!(parts, vec!["", "a", "b"]);
}
#[test]
fn pattern_path_root_leading_separator() {
assert_eq!(pattern_path_root(""), Some(PathBuf::from("/")));
}
#[test]
fn pattern_path_root_drive_letters() {
assert_eq!(pattern_path_root("c:"), Some(PathBuf::from("c:/")));
assert_eq!(pattern_path_root("C:"), Some(PathBuf::from("C:/")));
assert_eq!(pattern_path_root("Z:"), Some(PathBuf::from("Z:/")));
}
#[test]
fn pattern_path_root_rejects_non_drive_two_char_prefix() {
// "1:" is not a valid drive letter — must be alphabetic.
assert_eq!(pattern_path_root("1:"), None);
// Longer drive-like strings are not treated as roots.
assert_eq!(pattern_path_root("cd"), None);
assert_eq!(pattern_path_root("c:\\"), None);
assert_eq!(pattern_path_root("foo"), None);
}
#[test]
fn push_path_for_pattern_appends_with_forward_slash() {
let mut p = PathBuf::from(r"C:\Users\reuben");
push_path_for_pattern(&mut p, "foo");
// Forward slash is used as the appended separator, yielding mixed
// separators — acceptable because `normalize_path_separators` is
// applied downstream before display.
assert_eq!(p, PathBuf::from(r"C:\Users\reuben/foo"));
}
#[test]
fn push_path_for_pattern_no_double_separator() {
let mut p = PathBuf::from("C:/Users/reuben/");
push_path_for_pattern(&mut p, "foo");
assert_eq!(p, PathBuf::from("C:/Users/reuben/foo"));
let mut p = PathBuf::from(r"C:\Users\reuben\");
push_path_for_pattern(&mut p, "foo");
assert_eq!(p, PathBuf::from(r"C:\Users\reuben\foo"));
}
#[test]
fn push_path_for_pattern_onto_drive_root() {
let mut p = PathBuf::from("c:/");
push_path_for_pattern(&mut p, "foo");
assert_eq!(p, PathBuf::from("c:/foo"));
}
#[test]
fn push_path_for_pattern_onto_empty() {
let mut p = PathBuf::new();
push_path_for_pattern(&mut p, "foo");
// Empty path stays un-prefixed — we only add a separator between
// existing content and the new component.
assert_eq!(p, PathBuf::from("foo"));
}
#[test]
fn normalize_path_separators_converts_backslashes() {
use std::borrow::Cow;
// Already-forward-slashed input is borrowed (no allocation).
assert!(matches!(
normalize_path_separators("c:/foo/bar"),
Cow::Borrowed("c:/foo/bar")
));
// Mixed or backslashed input becomes owned and fully forward-slashed.
let normalized = normalize_path_separators(r"c:\foo\bar");
assert_eq!(normalized.as_ref(), "c:/foo/bar");
let normalized = normalize_path_separators(r"c:\foo/bar");
assert_eq!(normalized.as_ref(), "c:/foo/bar");
}
#[test]
fn default_case_insensitive_is_true() {
assert!(default_case_insensitive_path_expansion());
}
#[test]
fn has_executable_extension_is_case_insensitive() {
// Force the PATHEXT cache for this test's defaults.
assert!(has_executable_extension(Path::new("foo.exe")));
assert!(has_executable_extension(Path::new("foo.EXE")));
assert!(has_executable_extension(Path::new("foo.Cmd")));
assert!(!has_executable_extension(Path::new("foo.txt")));
assert!(!has_executable_extension(Path::new("foo")));
}
#[test]
fn pathext_entry_stem_strips_dot() {
assert_eq!(pathext_entry_stem(".exe"), "exe");
assert_eq!(pathext_entry_stem(".cmd"), "cmd");
// Tolerant: entries without a leading dot are returned as-is.
assert_eq!(pathext_entry_stem("exe"), "exe");
assert_eq!(pathext_entry_stem(""), "");
}
#[test]
fn resolve_executable_for_nonexistent_returns_none() {
// A path that cannot exist on any test host.
let path = PathBuf::from(r"C:\__brush_test_definitely_missing__");
assert!(resolve_executable(path).is_none());
}
}
+3
View File
@@ -0,0 +1,3 @@
pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
crate::sys::hostname::get()
}
+86
View File
@@ -0,0 +1,86 @@
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::unnecessary_wraps)]
use crate::error;
use std::path::PathBuf;
use std::sync::LazyLock;
/// Placeholder UID for non-elevated Windows processes.
///
/// Real Unix-style UIDs don't exist on Windows; this value is a
/// conventional non-root sentinel (matching the typical first
/// regular-user UID on Linux).
const NON_ELEVATED_UID: u32 = 1000;
/// Placeholder GID for non-elevated Windows processes (see [`NON_ELEVATED_UID`]).
const NON_ELEVATED_GID: u32 = 1000;
/// Cached elevation status. The underlying check queries the process token,
/// which can't change after process start, so it's safe to memoize.
static IS_ELEVATED: LazyLock<bool> = LazyLock::new(|| {
check_elevation::is_elevated().unwrap_or_else(|err| {
tracing::warn!("failed to determine process elevation: {err}");
false
})
});
pub(crate) fn get_user_home_dir(_username: &str) -> Option<PathBuf> {
// std::env::home_dir() doesn't support getting home dir for arbitrary users
// For now, we only support getting the current user's home dir
None
}
pub(crate) fn get_current_user_home_dir() -> Option<PathBuf> {
std::env::home_dir()
}
pub(crate) fn get_current_user_default_shell() -> Option<PathBuf> {
None
}
fn is_elevated() -> bool {
*IS_ELEVATED
}
pub(crate) fn is_root() -> bool {
is_elevated()
}
pub(crate) fn get_current_uid() -> Result<u32, error::Error> {
Ok(if is_elevated() { 0 } else { NON_ELEVATED_UID })
}
pub(crate) fn get_current_gid() -> Result<u32, error::Error> {
Ok(if is_elevated() { 0 } else { NON_ELEVATED_GID })
}
pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
Ok(if is_elevated() { 0 } else { NON_ELEVATED_UID })
}
pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
Ok(if is_elevated() { 0 } else { NON_ELEVATED_GID })
}
pub(crate) fn get_current_username() -> Result<String, error::Error> {
let username = whoami::username().map_err(std::io::Error::from)?;
Ok(username)
}
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn get_user_group_ids() -> Result<Vec<u32>, error::Error> {
// TODO(windows): implement some version of this for Windows
Ok(vec![])
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
// TODO(windows): implement some version of this for Windows
Ok(vec![])
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
// TODO(windows): implement some version of this for Windows
Ok(vec![])
}