edc0caeb0f
Addresses a broad audit of built-in shell utilities against their real counterparts: timeout gains signal delivery, -s/-k/--preserve-status/--foreground/-v, and GNU exit codes; diff defaults to normal format and gains -w/-b/-B/-i/-c/-x/-L/-s/--strip-trailing-cr and proper -r gating; find fixes -newerXY timestamp comparison direction, anchors -regex to whole paths, and gains BSD -perm +mode, -type lists, -size T/P suffixes, -E/-x/-s flags; date gains BSD -r epoch, -v adjustments, -j -f strptime, and non-greedy -I; tail/head accept obsolete -N/+N at any position with any file count; rg resolves case flags by last occurrence and gains --path-separator and clean -0 output; stat prints integer epochs for %X/%Y/%Z and gains BSD -s/-x/-t; cksum is registered as a builtin; truncate implements -o/--io-blocks and b/= size suffixes; sleep/timeout accept infinity; yes/errno/kill accept hyphen-prefixed operands; nohup -- cmd no longer runs --; which gains BSD -s.
992 lines
32 KiB
Rust
992 lines
32 KiB
Rust
//! Host plumbing for utility builtins (`cat`, `grep`, `sed`, `ls`, …).
|
|
//!
|
|
//! These builtins are ports of standalone command-line utilities: synchronous
|
|
//! programs that read `argv`, talk to fd 0/1/2, resolve relative paths against
|
|
//! the current directory, and exit with a status. [`Host`] hands them exactly
|
|
//! that view of the shell they run inside — as a value, threaded explicitly —
|
|
//! so no process-global or thread-local I/O state is involved: output lands on
|
|
//! the command's (possibly redirected or piped) file descriptors and relative
|
|
//! paths resolve against the *shell's* working directory rather than the host
|
|
//! process's.
|
|
//!
|
|
//! A utility implements [`Utility`]: a `clap` argument model plus a synchronous
|
|
//! [`Utility::run`] body. [`util`] wraps that into a [`Registration`] which
|
|
//!
|
|
//! 1. materializes process-substitution arguments (`diff <(a) <(b)`) into real
|
|
//! file descriptors,
|
|
//! 2. parses `argv`, rendering `--help`/`--version` on stdout and usage errors
|
|
//! on stderr with the utility's own exit status,
|
|
//! 3. runs the body on a blocking thread, so a slow utility never stalls the
|
|
//! async runtime and concurrent pipeline stages stay isolated,
|
|
//! 4. observes the shell's cancellation token (abort/`timeout`), and
|
|
//! 5. contains panics at the builtin boundary instead of taking down the
|
|
//! long-lived host process.
|
|
|
|
// The whole module is API consumed by the feature-gated utility modules; a build
|
|
// with no utility features enabled legitimately uses none of it.
|
|
#![allow(dead_code, reason = "consumed by the feature-gated utility modules")]
|
|
|
|
use std::{
|
|
cell::Cell,
|
|
collections::HashMap,
|
|
ffi::OsString,
|
|
io::{self, Read, Write},
|
|
marker::PhantomData,
|
|
panic::{AssertUnwindSafe, catch_unwind},
|
|
path::{Path, PathBuf},
|
|
time::Duration,
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
};
|
|
|
|
use brush_core::{
|
|
Error, ExecutionContext, ExecutionResult, ShellExtensions,
|
|
builtins::{self, Registration},
|
|
openfiles::{self, OpenFile, OpenFiles},
|
|
};
|
|
|
|
/// A command-line utility implemented as a shell builtin.
|
|
///
|
|
/// Implementors supply the `clap` argument model (via `derive(Parser)`, or
|
|
/// [`matches_parser!`] for builder-style definitions) and a synchronous body.
|
|
/// Register with [`util`].
|
|
pub(crate) trait Utility: clap::Parser + Send + Sync + 'static {
|
|
/// Program name, used in diagnostics (`sed: -e expression #1: …`).
|
|
const NAME: &'static str;
|
|
|
|
/// Exit status for a usage error. Most GNU utilities use 1; the
|
|
/// `ls`/`grep`/`cmp` families reserve 1 for "differences found" and use 2.
|
|
const USAGE_ERROR: u8 = 1;
|
|
|
|
/// Rewrites raw `argv` before clap parses it.
|
|
///
|
|
/// A few utilities accept syntax clap cannot model — GNU's obsolete
|
|
/// `head -5` count form, for instance. `argv[0]` is the command name.
|
|
/// Returning `Err(message)` reports `<name>: <message>` on stderr and exits
|
|
/// with [`Utility::USAGE_ERROR`]. The default is the identity.
|
|
fn rewrite_argv(argv: Vec<OsString>) -> Result<Vec<OsString>, String> {
|
|
Ok(argv)
|
|
}
|
|
|
|
/// Runs the utility to completion, returning its exit status.
|
|
///
|
|
/// Called on a blocking thread, so blocking reads, `rayon`, and long
|
|
/// filesystem walks are all fine. Long-running loops should poll
|
|
/// [`Host::is_cancelled`] so shell abort/`timeout` is observed promptly.
|
|
fn run(self, host: &mut Host) -> i32;
|
|
}
|
|
|
|
/// The shell as a utility builtin sees it: standard streams, working
|
|
/// directory, exported environment, cancellation, and accumulated exit status.
|
|
///
|
|
/// The three streams are public fields rather than accessors so a utility can
|
|
/// hold `&mut` borrows of two of them at once (reading stdin while writing
|
|
/// stdout is the common case).
|
|
pub(crate) struct Host {
|
|
/// Standard input. Reads observe cancellation, so a blocked pipe read
|
|
/// returns EOF on abort instead of hanging the shell.
|
|
pub stdin: Stdin,
|
|
/// Standard output; the null device when fd 1 is closed.
|
|
pub stdout: OpenFile,
|
|
/// Standard error; the null device when fd 2 is closed.
|
|
pub stderr: OpenFile,
|
|
|
|
name: String,
|
|
cwd: PathBuf,
|
|
env: HashMap<String, String>,
|
|
cancel: Arc<AtomicBool>,
|
|
exit_code: i32,
|
|
stdin_is_search_input: bool,
|
|
}
|
|
|
|
struct CancelOnDrop(Arc<AtomicBool>);
|
|
|
|
impl Drop for CancelOnDrop {
|
|
fn drop(&mut self) {
|
|
self.0.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
impl Host {
|
|
/// The name the utility was invoked as. Differs from [`Utility::NAME`] when
|
|
/// one implementation backs several builtins (`grep` and `rg`).
|
|
pub fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
/// The shell working directory that relative paths resolve against.
|
|
pub fn cwd(&self) -> &Path {
|
|
&self.cwd
|
|
}
|
|
|
|
/// Resolves `path` against [`Host::cwd`]; absolute paths pass through.
|
|
///
|
|
/// Every path argument must go through this before touching the
|
|
/// filesystem: the host process's current directory is unrelated to the
|
|
/// shell's.
|
|
pub fn resolve(&self, path: impl AsRef<Path>) -> PathBuf {
|
|
let normalized_path = brush_core::sys::fs::normalize_shell_path(path.as_ref());
|
|
let path = normalized_path.as_ref();
|
|
if path.is_absolute() {
|
|
path.to_path_buf()
|
|
} else {
|
|
self.cwd.join(path)
|
|
}
|
|
}
|
|
|
|
/// Looks up an exported shell variable.
|
|
///
|
|
/// The shell's exported variables are *not* present in the host process
|
|
/// environment, so `std::env::var` would miss them.
|
|
pub fn var(&self, key: &str) -> Option<&str> {
|
|
self.env.get(key).map(String::as_str)
|
|
}
|
|
|
|
/// The exported shell environment, for building a child process
|
|
/// environment (`env_clear().envs(host.env())`).
|
|
pub fn env(&self) -> impl Iterator<Item = (&str, &str)> {
|
|
self.env.iter().map(|(k, v)| (k.as_str(), v.as_str()))
|
|
}
|
|
|
|
/// Whether the host has asked this invocation to stop (shell abort or
|
|
/// `timeout`). Long internal loops — recursive directory walks in
|
|
/// particular — poll this so cancellation is observed without waiting for
|
|
/// stdin or for the whole work item to finish.
|
|
pub fn is_cancelled(&self) -> bool {
|
|
self.cancel.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// A cancellation flag that can be moved into worker threads and walker
|
|
/// callbacks.
|
|
pub fn cancel_flag(&self) -> Arc<AtomicBool> {
|
|
Arc::clone(&self.cancel)
|
|
}
|
|
|
|
/// Whether stdin is a shell pipe or custom stream, and so should be treated
|
|
/// as implicit input rather than a terminal. `rg PATTERN` uses this to
|
|
/// decide between searching stdin and searching `.`.
|
|
pub const fn stdin_is_search_input(&self) -> bool {
|
|
self.stdin_is_search_input
|
|
}
|
|
|
|
/// Records a non-zero exit status while processing continues (the
|
|
/// `cat a missing b` case: report, keep going, exit 1).
|
|
pub const fn fail(&mut self, code: i32) {
|
|
if code != 0 {
|
|
self.exit_code = code;
|
|
}
|
|
}
|
|
|
|
/// The status accumulated via [`Host::fail`]; 0 when nothing failed.
|
|
pub const fn exit_code(&self) -> i32 {
|
|
self.exit_code
|
|
}
|
|
|
|
/// Writes `<name>: <message>` to stderr and records exit status `code`.
|
|
pub fn error(&mut self, message: impl std::fmt::Display, code: i32) {
|
|
let _ = writeln!(self.stderr, "{}: {message}", self.name);
|
|
self.fail(code);
|
|
}
|
|
|
|
/// Duplicates stdout, for utilities that hand a writer to a helper thread.
|
|
pub fn stdout_clone(&self) -> OpenFile {
|
|
self.stdout.clone()
|
|
}
|
|
|
|
/// Duplicates stderr, for utilities that hand a writer to a helper thread.
|
|
pub fn stderr_clone(&self) -> OpenFile {
|
|
self.stderr.clone()
|
|
}
|
|
|
|
/// A launcher for child processes started by this utility.
|
|
///
|
|
/// Owned and `Clone`, so it can move into worker threads and into helper
|
|
/// types that never see the `Host` itself — `sort --compress-program` spawns
|
|
/// its compressor from inside the temp-file abstraction, for instance.
|
|
pub fn child_env(&self) -> ChildEnv {
|
|
ChildEnv {
|
|
cwd: self.cwd.clone(),
|
|
env: Arc::new(
|
|
self
|
|
.env
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect(),
|
|
),
|
|
stderr: self.stderr.clone(),
|
|
}
|
|
}
|
|
|
|
/// Runs `command` with stdin from the null device and stdout/stderr piped
|
|
/// back into this host's streams, returning the child's exit status.
|
|
///
|
|
/// The host's streams are in-process `Write` handles (pipes or in-memory
|
|
/// buffers), not inheritable descriptors, and the process's own fd 0/1/2
|
|
/// belong to the TUI — a child must never inherit stdio. Child stdout
|
|
/// streams through on the calling thread while a helper thread drains
|
|
/// stderr into a buffer, which is forwarded once the child exits.
|
|
///
|
|
/// Callers remain responsible for `current_dir` and the child environment
|
|
/// (`env_clear().envs(host.env())`).
|
|
pub fn run_captured(
|
|
&mut self,
|
|
command: &mut std::process::Command,
|
|
) -> io::Result<std::process::ExitStatus> {
|
|
command
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped());
|
|
let mut child = command.spawn()?;
|
|
|
|
let mut child_err = child.stderr.take();
|
|
let stderr_thread = std::thread::spawn(move || {
|
|
let mut buf = Vec::new();
|
|
if let Some(err) = child_err.as_mut() {
|
|
let _ = err.read_to_end(&mut buf);
|
|
}
|
|
buf
|
|
});
|
|
|
|
if let Some(mut out) = child.stdout.take() {
|
|
let _ = io::copy(&mut out, &mut self.stdout);
|
|
}
|
|
let status = child.wait();
|
|
if let Ok(buf) = stderr_thread.join() {
|
|
let _ = self.stderr.write_all(&buf);
|
|
}
|
|
status
|
|
}
|
|
}
|
|
|
|
/// A shell-faithful launcher for child processes started by a utility builtin.
|
|
///
|
|
/// Carries the three things a child must inherit from the *shell* rather than
|
|
/// from the host process: the working directory, the exported environment
|
|
/// (which is also what `PATH` lookup resolves against, so a program installed
|
|
/// only on the shell's `PATH` is found), and a duplicate of the command's
|
|
/// standard error.
|
|
///
|
|
/// That last one matters more than it looks: the host process's fd 2 belongs to
|
|
/// the TUI, so a child left with inherited stderr writes straight into the
|
|
/// rendered frame. [`ChildEnv::command`] therefore always pipes stderr, and
|
|
/// [`ChildEnv::forward_stderr`] drains it to the command's own fd 2.
|
|
#[derive(Clone)]
|
|
pub(crate) struct ChildEnv {
|
|
cwd: PathBuf,
|
|
env: Arc<Vec<(String, String)>>,
|
|
stderr: OpenFile,
|
|
}
|
|
|
|
impl ChildEnv {
|
|
/// Builds a `Command` for `program` with the shell's working directory and
|
|
/// environment, and with stderr piped.
|
|
///
|
|
/// Stdin and stdout are left untouched for the caller to wire; they default
|
|
/// to inherited, so a caller that leaves them alone MUST redirect them.
|
|
pub fn command(&self, program: impl AsRef<std::ffi::OsStr>) -> std::process::Command {
|
|
let mut command = std::process::Command::new(program);
|
|
command
|
|
.current_dir(&self.cwd)
|
|
.env_clear()
|
|
.envs(self.env.iter().map(|(k, v)| (k, v)))
|
|
.stderr(std::process::Stdio::piped());
|
|
command
|
|
}
|
|
|
|
/// Drains a child's piped stderr into the command's standard error on a
|
|
/// helper thread.
|
|
///
|
|
/// The returned handle should be joined once the child has exited, so the
|
|
/// diagnostic lands before the utility reports its own result. Dropping the
|
|
/// handle detaches the thread, which is only correct if nothing downstream
|
|
/// depends on the ordering.
|
|
pub fn forward_stderr(
|
|
&self,
|
|
mut child_stderr: std::process::ChildStderr,
|
|
) -> std::thread::JoinHandle<()> {
|
|
let mut stderr = self.stderr.clone();
|
|
std::thread::spawn(move || {
|
|
let _ = io::copy(&mut child_stderr, &mut stderr);
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Standard input for a utility builtin: the command's fd 0 plus the
|
|
/// cancellation flag.
|
|
///
|
|
/// On unix, when fd 0 is a real descriptor, reads wait for readiness in short
|
|
/// slices so an abort or `timeout` is observed even when input never arrives on
|
|
/// a blocked pipe; the utility then sees EOF and unwinds cleanly rather than
|
|
/// leaving a detached thread writing to descriptors the host has moved on from.
|
|
pub(crate) struct Stdin {
|
|
file: OpenFile,
|
|
#[cfg_attr(not(unix), allow(dead_code, reason = "readiness polling is unix-only"))]
|
|
fd: Option<i32>,
|
|
cancel: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Stdin {
|
|
/// Mirror of `std::io::Stdin::lock`; the handle is already the lockable
|
|
/// target, so this is the identity.
|
|
pub const fn lock(&mut self) -> &mut Self {
|
|
self
|
|
}
|
|
|
|
/// The underlying open file, for utilities that need to inspect fd 0
|
|
/// (`is_terminal`) or hand it to a child process.
|
|
pub const fn file(&self) -> &OpenFile {
|
|
&self.file
|
|
}
|
|
}
|
|
|
|
impl Read for Stdin {
|
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
if self.cancel.load(Ordering::Relaxed) {
|
|
return Ok(0);
|
|
}
|
|
#[cfg(unix)]
|
|
if let Some(fd) = self.fd {
|
|
loop {
|
|
if self.cancel.load(Ordering::Relaxed) {
|
|
return Ok(0);
|
|
}
|
|
let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 };
|
|
// SAFETY: one `pollfd` valid for the call; `fd` is owned by the
|
|
// live `OpenFile` held in this struct.
|
|
let ready = unsafe { libc::poll(&mut pfd, 1, 200) };
|
|
if ready < 0 {
|
|
let err = io::Error::last_os_error();
|
|
if err.kind() == io::ErrorKind::Interrupted {
|
|
continue;
|
|
}
|
|
return Err(err);
|
|
}
|
|
if ready > 0 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
self.file.read(buf)
|
|
}
|
|
}
|
|
|
|
thread_local! {
|
|
/// Depth of active utility bodies on this thread. The native crash hook
|
|
/// reads this from inside a panic (see [`panic_scope_active`]) to decide
|
|
/// whether the panic is about to be caught; a `Cell` is used because the
|
|
/// panicking code may hold other borrows, and a `RefCell` borrow there
|
|
/// would panic again and abort the process.
|
|
static PANIC_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
|
|
}
|
|
|
|
/// Whether a utility builtin body is running on the current thread.
|
|
///
|
|
/// A panic raised here is, by construction, about to be caught at the builtin
|
|
/// boundary, so the native crash hook treats it as recoverable and keeps it out
|
|
/// of the user-facing crash report.
|
|
#[must_use]
|
|
pub fn panic_scope_active() -> bool {
|
|
PANIC_SCOPE_DEPTH.with(|depth| depth.get() > 0)
|
|
}
|
|
|
|
static RAYON_GLOBAL_POOL_AVAILABLE: AtomicBool = AtomicBool::new(!cfg!(target_os = "windows"));
|
|
|
|
/// Records whether utility builtins may use Rayon's process-global worker pool
|
|
/// without risking lazy initialization under Windows commit pressure.
|
|
pub fn set_rayon_global_pool_available(available: bool) {
|
|
RAYON_GLOBAL_POOL_AVAILABLE.store(available, Ordering::SeqCst);
|
|
}
|
|
|
|
/// Whether utility builtins may enter Rayon's process-global worker pool.
|
|
#[must_use]
|
|
pub fn rayon_global_pool_available() -> bool {
|
|
RAYON_GLOBAL_POOL_AVAILABLE.load(Ordering::SeqCst)
|
|
}
|
|
|
|
/// Indents all but the first line of a usage string by 7 spaces, aligning
|
|
/// continuation lines under clap's `Usage: ` prefix.
|
|
pub(crate) fn format_usage(usage: &str) -> String {
|
|
debug_assert!(
|
|
!usage.contains("{}"),
|
|
"usage strings must name the command explicitly, not via a '{{}}' placeholder"
|
|
);
|
|
usage.replace('\n', "\n ")
|
|
}
|
|
|
|
/// Borrows an `OsStr` as raw bytes.
|
|
///
|
|
/// Unix strings are arbitrary byte sequences, so this is free there. On Windows
|
|
/// only well-formed UTF-16 has a UTF-8 byte view, so an ill-formed value yields
|
|
/// `None`; callers report that as an invalid argument.
|
|
pub(crate) fn os_bytes(value: &std::ffi::OsStr) -> Option<&[u8]> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::ffi::OsStrExt;
|
|
Some(value.as_bytes())
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
value.to_str().map(str::as_bytes)
|
|
}
|
|
}
|
|
|
|
/// Borrows an `OsStr` as raw bytes, substituting replacement characters for
|
|
/// anything unrepresentable. For diagnostics, where losing a byte beats failing.
|
|
pub(crate) fn os_bytes_lossy(value: &std::ffi::OsStr) -> std::borrow::Cow<'_, [u8]> {
|
|
match os_bytes(value) {
|
|
Some(bytes) => std::borrow::Cow::Borrowed(bytes),
|
|
None => std::borrow::Cow::Owned(value.to_string_lossy().into_owned().into_bytes()),
|
|
}
|
|
}
|
|
|
|
/// Parses a GNU-style duration: a decimal number with an optional `s`/`m`/`h`/`d`
|
|
/// suffix, as accepted by `sleep` and `timeout`.
|
|
///
|
|
/// GNU also accepts `inf`/`infinity` (optionally signed `+`, any case);
|
|
/// infinite and overflowing values saturate to [`Duration::MAX`]. Callers
|
|
/// treat such durations as "sleep until cancelled". Sub-millisecond precision
|
|
/// is preserved: GNU `sleep 0.0001` really sleeps 100 microseconds.
|
|
pub(crate) fn parse_duration(input: &str) -> Option<Duration> {
|
|
let trimmed = input.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let unsigned = trimmed.strip_prefix('+').unwrap_or(trimmed);
|
|
if unsigned.eq_ignore_ascii_case("inf") || unsigned.eq_ignore_ascii_case("infinity") {
|
|
return Some(Duration::MAX);
|
|
}
|
|
let (number, multiplier) = match trimmed.chars().last()? {
|
|
's' => (&trimmed[..trimmed.len() - 1], 1.0),
|
|
'm' => (&trimmed[..trimmed.len() - 1], 60.0),
|
|
'h' => (&trimmed[..trimmed.len() - 1], 3600.0),
|
|
'd' => (&trimmed[..trimmed.len() - 1], 86400.0),
|
|
ch if ch.is_ascii_alphabetic() => return None,
|
|
_ => (trimmed, 1.0),
|
|
};
|
|
let value = number.parse::<f64>().ok()?;
|
|
if value.is_nan() || value.is_sign_negative() {
|
|
return None;
|
|
}
|
|
if value.is_infinite() {
|
|
return Some(Duration::MAX);
|
|
}
|
|
// Only overflow remains once NaN and negatives are excluded; saturate.
|
|
Duration::try_from_secs_f64(value * multiplier).map_or(Some(Duration::MAX), Some)
|
|
}
|
|
|
|
|
|
/// Shell-quotes `arg` when rebuilding a command line for a child process.
|
|
///
|
|
/// `timeout` and `nohup` reconstruct the command they were handed so it can be
|
|
/// re-parsed by a shell; anything that could be re-split or re-expanded must be
|
|
/// quoted first.
|
|
pub(crate) fn quote_arg(arg: &str) -> String {
|
|
if arg.is_empty() {
|
|
return "''".to_string();
|
|
}
|
|
let safe = arg
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '+'));
|
|
if safe {
|
|
return arg.to_string();
|
|
}
|
|
let escaped = arg.replace('\'', "'\"'\"'");
|
|
format!("'{escaped}'")
|
|
}
|
|
|
|
/// Reads a boolean "disable" flag for the uutils builtins from the session
|
|
/// environment (preferred) then the process environment, mirroring the nohup
|
|
/// builtin gate. Truthy = present and not "", "0", or "false".
|
|
|
|
/// Returns the [`Registration`] for a [`Utility`].
|
|
pub(crate) fn util<U: Utility, SE: ShellExtensions>() -> Registration<SE> {
|
|
builtins::builtin::<Util<U>, SE>()
|
|
}
|
|
|
|
/// Adapter turning a [`Utility`] into a brush builtin.
|
|
///
|
|
/// Holds the raw argument vector rather than a parsed `U`: process-substitution
|
|
/// arguments can only be materialized once the shell is in hand, which happens
|
|
/// in [`builtins::Command::execute`], and parse failures must be reported on the
|
|
/// utility's own terms (help on stdout, usage errors with the utility's exit
|
|
/// status) rather than through brush's generic usage-error path.
|
|
pub(crate) struct Util<U: Utility> {
|
|
argv: Vec<String>,
|
|
_marker: PhantomData<fn() -> U>,
|
|
}
|
|
|
|
impl<U: Utility> clap::FromArgMatches for Util<U> {
|
|
fn from_arg_matches(_matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
|
|
Ok(Self { argv: Vec::new(), _marker: PhantomData })
|
|
}
|
|
|
|
fn update_from_arg_matches(&mut self, _matches: &clap::ArgMatches) -> Result<(), clap::Error> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<U: Utility> clap::CommandFactory for Util<U> {
|
|
fn command() -> clap::Command {
|
|
U::command()
|
|
}
|
|
|
|
fn command_for_update() -> clap::Command {
|
|
U::command_for_update()
|
|
}
|
|
}
|
|
|
|
impl<U: Utility> clap::Parser for Util<U> {}
|
|
|
|
impl<U: Utility> builtins::Command for Util<U> {
|
|
type Error = Error;
|
|
|
|
fn new<I>(args: I) -> Result<Self, clap::Error>
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
Ok(Self { argv: args.into_iter().collect(), _marker: PhantomData })
|
|
}
|
|
|
|
async fn execute<SE: ShellExtensions>(
|
|
&self,
|
|
context: ExecutionContext<'_, SE>,
|
|
) -> Result<ExecutionResult, Self::Error> {
|
|
run_utility::<U, SE>(context, self.argv.clone()).await
|
|
}
|
|
}
|
|
|
|
/// Drives a utility from raw arguments to an exit status.
|
|
async fn run_utility<U: Utility, SE: ShellExtensions>(
|
|
context: ExecutionContext<'_, SE>,
|
|
argv: Vec<String>,
|
|
) -> Result<ExecutionResult, Error> {
|
|
// Capture everything owned *before* the first await so the returned future
|
|
// stays `Send`: the borrowed `ExecutionContext` (and its `&mut Shell`) is
|
|
// dropped before we await the blocking task.
|
|
#[cfg_attr(not(unix), expect(unused_mut, reason = "rewritten only on unix"))]
|
|
let mut argv: Vec<OsString> = argv.into_iter().map(OsString::from).collect();
|
|
#[cfg(unix)]
|
|
let process_substitution_fds = materialize_process_substitution_fds(&context, &mut argv)?;
|
|
|
|
let argv = match U::rewrite_argv(argv) {
|
|
Ok(argv) => argv,
|
|
Err(message) => {
|
|
let _ = writeln!(context.stderr(), "{}: {message}", U::NAME);
|
|
return Ok(ExecutionResult::new(U::USAGE_ERROR));
|
|
},
|
|
};
|
|
|
|
let parsed = match U::try_parse_from(&argv) {
|
|
Ok(parsed) => parsed,
|
|
Err(err) => {
|
|
// clap reports `--help` and `--version` as errors; those belong on
|
|
// stdout with a success status, everything else on stderr.
|
|
let rendered = err.to_string();
|
|
if err.use_stderr() {
|
|
let _ = write!(context.stderr(), "{rendered}");
|
|
return Ok(ExecutionResult::new(U::USAGE_ERROR));
|
|
}
|
|
let _ = write!(context.stdout(), "{rendered}");
|
|
return Ok(ExecutionResult::success());
|
|
},
|
|
};
|
|
|
|
let mut host = build_host(&context, U::NAME)?;
|
|
let cancel = context.cancel_token();
|
|
let cancel_flag = host.cancel_flag();
|
|
let _cancel_on_drop = CancelOnDrop(Arc::clone(&cancel_flag));
|
|
drop(context);
|
|
|
|
let mut handle = tokio::task::spawn_blocking(move || {
|
|
#[cfg(unix)]
|
|
let _process_substitution_fds = process_substitution_fds;
|
|
run_caught::<U>(parsed, &mut host)
|
|
});
|
|
|
|
// Respect shell abort/`timeout`. On cancel we set the host's cancel flag,
|
|
// which makes a blocked stdin read return EOF; the utility unwinds cleanly
|
|
// (flushing what it already produced) and the blocking task completes. We
|
|
// await that completion before returning so no detached thread keeps
|
|
// writing to the command's (possibly redirected) descriptors.
|
|
let code = match cancel {
|
|
Some(token) => {
|
|
let token_check = token.clone();
|
|
tokio::select! {
|
|
biased;
|
|
() = token.cancelled() => {
|
|
cancel_flag.store(true, Ordering::Relaxed);
|
|
let _ = (&mut handle).await;
|
|
130
|
|
},
|
|
result = &mut handle => {
|
|
// If the token already fired, the task only finished because
|
|
// our cancel flag unblocked it — report interrupted.
|
|
if token_check.is_cancelled() { 130 } else { result.unwrap_or(1) }
|
|
},
|
|
}
|
|
},
|
|
None => handle.await.unwrap_or(1),
|
|
};
|
|
|
|
Ok(ExecutionResult::new((code & 0xff) as u8))
|
|
}
|
|
|
|
/// Runs a utility body, containing any panic at the builtin boundary.
|
|
///
|
|
/// A port that panics (an `unwrap` on a `BrokenPipe`, say) must not take down
|
|
/// the long-lived host process. With `panic = "unwind"` the panic unwinds to
|
|
/// here, where it becomes a non-zero exit plus a concise note on the command's
|
|
/// own stderr.
|
|
fn run_caught<U: Utility>(parsed: U, host: &mut Host) -> i32 {
|
|
struct Guard;
|
|
impl Drop for Guard {
|
|
fn drop(&mut self) {
|
|
PANIC_SCOPE_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
|
|
}
|
|
}
|
|
PANIC_SCOPE_DEPTH.with(|depth| depth.set(depth.get() + 1));
|
|
let _guard = Guard;
|
|
|
|
match catch_unwind(AssertUnwindSafe(|| parsed.run(host))) {
|
|
Ok(code) => code,
|
|
Err(_) => {
|
|
let _ = writeln!(host.stderr, "{}: internal error", U::NAME);
|
|
1
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Snapshots the command's streams, working directory, and exported
|
|
/// environment into an owned [`Host`] that can move to a blocking thread.
|
|
fn build_host<SE: ShellExtensions>(
|
|
context: &ExecutionContext<'_, SE>,
|
|
name: &str,
|
|
) -> Result<Host, Error> {
|
|
let stdin = context.try_fd(OpenFiles::STDIN_FD);
|
|
// On unix, capture the raw stdin fd so reads can poll it for cancellation;
|
|
// the `OpenFile` is kept alive by the `Stdin` below, so the fd stays valid.
|
|
#[cfg(unix)]
|
|
let stdin_fd: Option<i32> = {
|
|
use std::os::fd::AsRawFd;
|
|
stdin
|
|
.as_ref()
|
|
.and_then(|file| file.try_borrow_as_fd().ok())
|
|
.map(|fd| fd.as_raw_fd())
|
|
};
|
|
#[cfg(not(unix))]
|
|
let stdin_fd: Option<i32> = None;
|
|
let stdin_is_search_input = stdin
|
|
.as_ref()
|
|
.is_some_and(|file| matches!(file, OpenFile::PipeReader(_) | OpenFile::Stream(_)));
|
|
|
|
let mut env = HashMap::new();
|
|
for (key, var) in context.shell.env().iter_exported() {
|
|
if var.value().is_set() {
|
|
env.insert(key.clone(), var.value().to_cow_str(context.shell).into_owned());
|
|
}
|
|
}
|
|
|
|
let invoked = if context.command_name.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
context.command_name.clone()
|
|
};
|
|
|
|
// One flag, shared: the adapter flips it on cancellation, and a blocked
|
|
// `Stdin::read` must observe the very same flag or it never wakes.
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
|
|
Ok(Host {
|
|
stdin: Stdin {
|
|
file: or_null(stdin)?,
|
|
fd: stdin_fd,
|
|
cancel: Arc::clone(&cancel),
|
|
},
|
|
stdout: or_null(context.try_fd(OpenFiles::STDOUT_FD))?,
|
|
stderr: or_null(context.try_fd(OpenFiles::STDERR_FD))?,
|
|
name: invoked,
|
|
cwd: context.shell.working_dir().to_path_buf(),
|
|
env,
|
|
cancel,
|
|
exit_code: 0,
|
|
stdin_is_search_input,
|
|
})
|
|
}
|
|
|
|
/// Substitutes the null device for a closed descriptor, so a utility reading
|
|
/// from or writing to it sees EOF / discards output instead of failing.
|
|
fn or_null(file: Option<OpenFile>) -> Result<OpenFile, Error> {
|
|
match file {
|
|
Some(file) => Ok(file),
|
|
None => openfiles::null(),
|
|
}
|
|
}
|
|
|
|
/// Recognizes brush's process-substitution arguments (`/dev/fd/<shell fd>`).
|
|
#[cfg(unix)]
|
|
fn process_substitution_fd(arg: &std::ffi::OsStr) -> Option<brush_core::ShellFd> {
|
|
arg.to_str()?
|
|
.strip_prefix("/dev/fd/")?
|
|
.parse::<brush_core::ShellFd>()
|
|
.ok()
|
|
}
|
|
|
|
/// Rewrites `/dev/fd/<shell fd>` arguments to real descriptors of the host
|
|
/// process, returning the owned descriptors that must stay alive for the
|
|
/// duration of the utility.
|
|
///
|
|
/// Brush allocates process-substitution pipes in its own descriptor table, so
|
|
/// the shell fd number in the argument is meaningless to `open`.
|
|
#[cfg(unix)]
|
|
fn materialize_process_substitution_fds<SE: ShellExtensions>(
|
|
context: &ExecutionContext<'_, SE>,
|
|
argv: &mut [OsString],
|
|
) -> Result<Vec<std::os::fd::OwnedFd>, Error> {
|
|
use std::os::fd::AsRawFd;
|
|
|
|
let mut fds = Vec::new();
|
|
for arg in argv {
|
|
let Some(shell_fd) = process_substitution_fd(arg) else {
|
|
continue;
|
|
};
|
|
let Some(file) = context.try_fd(shell_fd) else {
|
|
continue;
|
|
};
|
|
let fd = file.try_borrow_as_fd()?.try_clone_to_owned()?;
|
|
*arg = OsString::from(format!("/dev/fd/{}", fd.as_raw_fd()));
|
|
fds.push(fd);
|
|
}
|
|
Ok(fds)
|
|
}
|
|
|
|
/// Implements `clap::Parser` for a builder-style utility: `$ty` stores the
|
|
/// `ArgMatches` produced by `$app` in a field named `matches`.
|
|
///
|
|
/// Ports whose upstream argument model is built with `clap::Command::new(…)`
|
|
/// use this instead of rewriting dozens of arguments into `derive(Parser)`
|
|
/// form. Brush still renders `--help`, usage, and man content from `$app`.
|
|
#[allow(unused_macros, reason = "used by utility modules, which are feature-gated")]
|
|
macro_rules! matches_parser {
|
|
($ty:ident, $app:path) => {
|
|
impl clap::FromArgMatches for $ty {
|
|
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
|
|
Ok(Self { matches: matches.clone() })
|
|
}
|
|
|
|
fn update_from_arg_matches(
|
|
&mut self,
|
|
matches: &clap::ArgMatches,
|
|
) -> Result<(), clap::Error> {
|
|
self.matches = matches.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl clap::CommandFactory for $ty {
|
|
fn command() -> clap::Command {
|
|
$app()
|
|
}
|
|
|
|
fn command_for_update() -> clap::Command {
|
|
$app()
|
|
}
|
|
}
|
|
|
|
impl clap::Parser for $ty {}
|
|
};
|
|
}
|
|
|
|
#[allow(unused_imports, reason = "used by utility modules, which are feature-gated")]
|
|
pub(crate) use matches_parser;
|
|
|
|
#[cfg(test)]
|
|
mod testing {
|
|
//! In-memory [`Host`] construction for unit tests.
|
|
|
|
use parking_lot::Mutex;
|
|
|
|
use super::{
|
|
Arc, AtomicBool, HashMap, Host, OpenFile, OsString, PathBuf, Read, Stdin, Utility, Write, io,
|
|
openfiles, run_caught,
|
|
};
|
|
|
|
/// Captured in-memory output from [`Host::for_test`].
|
|
pub(crate) struct Capture {
|
|
stdout: Arc<Mutex<Vec<u8>>>,
|
|
stderr: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
|
|
impl Capture {
|
|
/// Raw bytes the utility wrote to stdout.
|
|
pub fn stdout(&self) -> Vec<u8> {
|
|
self.stdout.lock().clone()
|
|
}
|
|
|
|
/// Raw bytes the utility wrote to stderr.
|
|
pub fn stderr(&self) -> Vec<u8> {
|
|
self.stderr.lock().clone()
|
|
}
|
|
|
|
/// Stdout as a lossy string, for readable assertions.
|
|
pub fn out(&self) -> String {
|
|
String::from_utf8_lossy(&self.stdout()).into_owned()
|
|
}
|
|
|
|
/// Stderr as a lossy string, for readable assertions.
|
|
pub fn err(&self) -> String {
|
|
String::from_utf8_lossy(&self.stderr()).into_owned()
|
|
}
|
|
}
|
|
|
|
impl Host {
|
|
/// Builds a host backed by in-memory streams.
|
|
///
|
|
/// Returns the host plus a [`Capture`] over the same buffers, so a test
|
|
/// can run a utility and then assert on what it wrote.
|
|
pub(crate) fn for_test(
|
|
name: &str,
|
|
stdin: impl Into<Vec<u8>>,
|
|
cwd: impl Into<PathBuf>,
|
|
) -> (Self, Capture) {
|
|
let capture = Capture {
|
|
stdout: Arc::new(Mutex::new(Vec::new())),
|
|
stderr: Arc::new(Mutex::new(Vec::new())),
|
|
};
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
let host = Self {
|
|
stdin: Stdin {
|
|
file: OpenFile::Stream(Box::new(MemStream::reader(stdin.into()))),
|
|
fd: None,
|
|
cancel: Arc::clone(&cancel),
|
|
},
|
|
stdout: OpenFile::Stream(Box::new(MemStream::writer(Arc::clone(
|
|
&capture.stdout,
|
|
)))),
|
|
stderr: OpenFile::Stream(Box::new(MemStream::writer(Arc::clone(
|
|
&capture.stderr,
|
|
)))),
|
|
name: name.to_string(),
|
|
cwd: cwd.into(),
|
|
env: HashMap::new(),
|
|
cancel,
|
|
exit_code: 0,
|
|
stdin_is_search_input: false,
|
|
};
|
|
(host, capture)
|
|
}
|
|
|
|
/// Sets an exported variable on a test host.
|
|
pub(crate) fn set_test_var(&mut self, key: &str, value: &str) {
|
|
self.env.insert(key.to_string(), value.to_string());
|
|
}
|
|
|
|
/// Requests cancellation on a test host.
|
|
pub(crate) fn cancel_for_test(&self) {
|
|
self.cancel.store(true, super::Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn resolves_msys_drive_aliases_to_native_drive() {
|
|
let (host, _) = Host::for_test("test", "", r"C:\workspace");
|
|
|
|
assert_eq!(host.resolve("/c/Users/Adam/file.txt"), PathBuf::from(r"C:\Users\Adam\file.txt"));
|
|
}
|
|
|
|
/// Parses `argv` and runs `U` against an in-memory host, mirroring what the
|
|
/// registered builtin does: `argv[0]` is the command name, clap failures are
|
|
/// reported the same way, and panics are contained.
|
|
pub(crate) fn run_util<U: Utility>(
|
|
argv: &[&str],
|
|
stdin: &str,
|
|
cwd: impl Into<PathBuf>,
|
|
) -> (i32, Capture) {
|
|
let (mut host, capture) = Host::for_test(U::NAME, stdin.as_bytes().to_vec(), cwd);
|
|
let full: Vec<OsString> = std::iter::once(OsString::from(U::NAME))
|
|
.chain(argv.iter().map(OsString::from))
|
|
.collect();
|
|
let full = match U::rewrite_argv(full) {
|
|
Ok(full) => full,
|
|
Err(message) => {
|
|
let _ = writeln!(host.stderr, "{}: {message}", U::NAME);
|
|
return (i32::from(U::USAGE_ERROR), capture);
|
|
},
|
|
};
|
|
let code = match U::try_parse_from(&full) {
|
|
Ok(parsed) => run_caught::<U>(parsed, &mut host),
|
|
Err(err) => {
|
|
let rendered = err.to_string();
|
|
if err.use_stderr() {
|
|
let _ = write!(host.stderr, "{rendered}");
|
|
i32::from(U::USAGE_ERROR)
|
|
} else {
|
|
let _ = write!(host.stdout, "{rendered}");
|
|
0
|
|
}
|
|
},
|
|
};
|
|
(code, capture)
|
|
}
|
|
|
|
/// An in-memory [`openfiles::Stream`]: a cursor over fixed input, or an
|
|
/// appending writer over a shared buffer.
|
|
#[derive(Clone)]
|
|
struct MemStream {
|
|
input: Arc<Mutex<io::Cursor<Vec<u8>>>>,
|
|
output: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
|
|
impl MemStream {
|
|
fn reader(data: Vec<u8>) -> Self {
|
|
Self {
|
|
input: Arc::new(Mutex::new(io::Cursor::new(data))),
|
|
output: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
fn writer(output: Arc<Mutex<Vec<u8>>>) -> Self {
|
|
Self { input: Arc::new(Mutex::new(io::Cursor::new(Vec::new()))), output }
|
|
}
|
|
}
|
|
|
|
impl Read for MemStream {
|
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
self.input.lock().read(buf)
|
|
}
|
|
}
|
|
|
|
impl Write for MemStream {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
self.output.lock().extend_from_slice(buf);
|
|
Ok(buf.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl openfiles::Stream for MemStream {
|
|
fn clone_box(&self) -> Box<dyn openfiles::Stream> {
|
|
Box::new(self.clone())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn try_clone_to_owned(&self) -> Result<std::os::fd::OwnedFd, super::Error> {
|
|
Err(brush_core::error::ErrorKind::CannotConvertToNativeFd.into())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn try_borrow_as_fd(&self) -> Result<std::os::fd::BorrowedFd<'_>, super::Error> {
|
|
Err(brush_core::error::ErrorKind::CannotConvertToNativeFd.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(unused_imports, reason = "used by utility test modules, which are feature-gated")]
|
|
pub(crate) use testing::{Capture, run_util};
|