//! In-process shell builtins backed by vendored, patched uutils utilities. //! //! Each builtin installs a [`pi_uutils_ctx`] scope — the command's stdio file //! descriptors, the shell working directory, and the shell's exported //! environment — on a dedicated blocking thread, then invokes the patched //! utility's `run` entry point. Running on a blocking thread keeps the //! thread-local context isolated across concurrent pipeline stages and avoids //! blocking the async runtime on synchronous utility I/O. #[cfg(unix)] use std::ffi::OsStr; use std::{ collections::HashMap, ffi::OsString, io::{self, Read, Write}, panic::catch_unwind, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, }; #[cfg(unix)] use brush_core::ShellFd; use brush_core::{ Error, builtins::{BoxFuture, ContentOptions, ContentType, Registration}, commands::{CommandArg, ExecutionContext}, extensions::ShellExtensions, openfiles::{OpenFile, OpenFiles}, results::ExecutionResult, }; /// Signature of a patched uutils `run` entry point: consumes `argv` (with the /// command name at index 0) and returns a process-style exit code. type UutilRun = fn(Vec) -> i32; #[cfg(unix)] fn process_substitution_fd(arg: &OsStr) -> Option { let fd = arg.to_str()?.strip_prefix("/dev/fd/")?.parse().ok()?; (fd > OpenFiles::STDERR_FD).then_some(fd) } #[cfg(unix)] fn materialize_process_substitution_fds( context: &ExecutionContext<'_, SE>, argv: &mut [OsString], ) -> Result, Error> { use std::os::fd::AsRawFd as _; 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) } /// Drives a patched uutils utility to completion under a [`pi_uutils_ctx`] /// scope derived from the command execution context. async fn run_uutil( context: ExecutionContext<'_, SE>, args: Vec, run: UutilRun, ) -> Result { // 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. let stdin = context.try_fd(OpenFiles::STDIN_FD); let stdout = context.try_fd(OpenFiles::STDOUT_FD); let stderr = context.try_fd(OpenFiles::STDERR_FD); let cwd = context.shell.working_dir().to_path_buf(); let cancel = context.cancel_token(); 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()); } } // On unix, capture the raw stdin fd so the context can poll it for // cancellation; the `OpenFile` is moved into (and kept alive by) the // blocking task below, so the fd stays valid for the poll loop. #[cfg(unix)] let stdin_fd: Option = { 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 = None; let stdin_is_search_input = stdin .as_ref() .is_some_and(|file| matches!(file, OpenFile::PipeReader(_) | OpenFile::Stream(_))); let cancel_flag = Arc::new(AtomicBool::new(false)); let scope_flag = Arc::clone(&cancel_flag); // brush passes the command name as the first `CommandArg`, which is exactly // the argv[0] uutils' argument parsing expects. #[cfg_attr(not(unix), allow(unused_mut))] let mut argv: Vec = args .iter() .map(|arg| OsString::from(arg.to_string())) .collect(); #[cfg(unix)] let process_substitution_fds = materialize_process_substitution_fds(&context, &mut argv)?; drop(context); let mut handle = tokio::task::spawn_blocking(move || { #[cfg(unix)] let _process_substitution_fds = process_substitution_fds; let stdin: Box = match stdin { Some(file) => Box::new(file), None => Box::new(io::empty()), }; let stdout: Box = match stdout { Some(file) => Box::new(file), None => Box::new(io::sink()), }; let stderr: Box = match stderr { Some(file) => Box::new(file), None => Box::new(io::sink()), }; pi_uutils_ctx::scope( pi_uutils_ctx::ScopeIo { stdin, stdin_fd, stdin_is_search_input, stdout, stderr, cwd, env, cancel: scope_flag, }, || run_caught(run, argv), ) }); // Respect bash abort/timeout. On cancel we set the context'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) fds. 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 uutils entry point, containing any panic at the in-process boundary. /// /// A vendored utility that panics (e.g. an `unwrap` on a `BrokenPipe`, the /// crash this guards — see uu-tail) must not take down the long-lived host. /// 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. The native /// crash hook recognizes the active uutils scope and keeps the recovered panic /// out of the user-facing crash report (it is still logged to disk). fn run_caught(run: UutilRun, argv: Vec) -> i32 { let name = argv .first() .map_or_else(|| String::from("command"), |arg| arg.to_string_lossy().into_owned()); if let Ok(code) = catch_unwind(|| run(argv)) { code } else { let _ = writeln!(pi_uutils_ctx::stderr(), "{name}: internal error"); 1 } } /// Minimal help/usage content for a uutils-backed builtin. The full utility /// renders its own `--help` through the context streams at runtime. #[allow( clippy::unnecessary_wraps, reason = "signature must match brush's CommandContentFunc fn pointer (Result)" )] fn uutil_content( name: &str, _content_type: ContentType, _options: &ContentOptions, ) -> Result { Ok(format!("{name}: {name} [uutils builtin]\n")) } /// Defines a `Registration` constructor that dispatches to a patched uutils /// `run` entry point with raw (unparsed-by-brush) arguments. macro_rules! uutil_builtin { ($vis:vis fn $reg_fn:ident => $run:path) => { $vis fn $reg_fn() -> Registration { fn execute( context: ExecutionContext<'_, SE>, args: Vec, ) -> BoxFuture<'_, Result> { Box::pin(run_uutil(context, args, $run)) } Registration { execute_func: execute::, content_func: uutil_content, disabled: false, special_builtin: false, declaration_builtin: false, transparent_background_wrapper: false, } } }; } uutil_builtin!(pub fn mkdir_builtin => uu_mkdir::run); uutil_builtin!(pub fn head_builtin => uu_head::run); uutil_builtin!(pub fn sort_builtin => uu_sort::run); uutil_builtin!(pub fn wc_builtin => uu_wc::run); uutil_builtin!(pub fn tail_builtin => uu_tail::run); uutil_builtin!(pub fn ls_builtin => uu_ls::run); uutil_builtin!(pub fn find_builtin => uu_find::run); uutil_builtin!(pub fn grep_builtin => pi_uu_grep::run); uutil_builtin!(pub fn rg_builtin => pi_uu_grep::run_rg); uutil_builtin!(pub fn rm_builtin => uu_rm::run); uutil_builtin!(pub fn mv_builtin => uu_mv::run); uutil_builtin!(pub fn cat_builtin => uu_cat::run); uutil_builtin!(pub fn uniq_builtin => uu_uniq::run); uutil_builtin!(pub fn base64_builtin => uu_base64::run); uutil_builtin!(pub fn cmp_builtin => crate::cmp::run); uutil_builtin!(pub fn md5sum_builtin => uu_md5sum::run); uutil_builtin!(pub fn sha1sum_builtin => uu_sha1sum::run); uutil_builtin!(pub fn sha224sum_builtin => uu_sha224sum::run); uutil_builtin!(pub fn sha256sum_builtin => uu_sha256sum::run); uutil_builtin!(pub fn sha384sum_builtin => uu_sha384sum::run); uutil_builtin!(pub fn sha512sum_builtin => uu_sha512sum::run); uutil_builtin!(pub fn b2sum_builtin => uu_b2sum::run); uutil_builtin!(pub fn basename_builtin => uu_basename::run); uutil_builtin!(pub fn dirname_builtin => uu_dirname::run); uutil_builtin!(pub fn readlink_builtin => uu_readlink::run); uutil_builtin!(pub fn realpath_builtin => uu_realpath::run); uutil_builtin!(pub fn touch_builtin => uu_touch::run); uutil_builtin!(pub fn stat_builtin => uu_stat::run); uutil_builtin!(pub fn date_builtin => uu_date::run); uutil_builtin!(pub fn mktemp_builtin => uu_mktemp::run); uutil_builtin!(pub fn seq_builtin => uu_seq::run); uutil_builtin!(pub fn yes_builtin => uu_yes::run); uutil_builtin!(pub fn printenv_builtin => uu_printenv::run); uutil_builtin!(pub fn ln_builtin => uu_ln::run); uutil_builtin!(pub fn truncate_builtin => uu_truncate::run); uutil_builtin!(pub fn tac_builtin => uu_tac::run); uutil_builtin!(pub fn nproc_builtin => uu_nproc::run); uutil_builtin!(pub fn uname_builtin => uu_uname::run); uutil_builtin!(pub fn whoami_builtin => uu_whoami::run); uutil_builtin!(pub fn hostname_builtin => uu_hostname::run); uutil_builtin!(pub fn diff_builtin => pi_uu_diff::run); uutil_builtin!(pub fn cut_builtin => uu_cut::run); uutil_builtin!(pub fn tee_builtin => uu_tee::run); uutil_builtin!(pub fn tr_builtin => uu_tr::run); uutil_builtin!(pub fn paste_builtin => uu_paste::run); uutil_builtin!(pub fn comm_builtin => uu_comm::run); uutil_builtin!(pub fn sed_builtin => uu_sed::run); uutil_builtin!(pub fn xargs_builtin => uu_xargs::run); uutil_builtin!(pub fn jq_builtin => jaq::run); uutil_builtin!(pub fn ts_builtin => crate::moreutils::ts::run); uutil_builtin!(pub fn sponge_builtin => crate::moreutils::sponge::run); uutil_builtin!(pub fn ifne_builtin => crate::moreutils::ifne::run); uutil_builtin!(pub fn isutf8_builtin => crate::moreutils::isutf8::run); uutil_builtin!(pub fn combine_builtin => crate::moreutils::combine::run); #[cfg(unix)] uutil_builtin!(pub fn errno_builtin => crate::moreutils::errno::run); #[cfg(test)] mod tests { use std::{ collections::HashMap, ffi::OsString, io::{self, Write}, path::PathBuf, sync::{Arc, atomic::AtomicBool}, }; use flume::Sender; use super::{UutilRun, run_caught}; /// `Send` writer that forwards every write onto a channel so a test can /// inspect what the utility wrote to the scope's stderr. struct ChanWriter(Sender>); impl Write for ChanWriter { fn write(&mut self, buf: &[u8]) -> io::Result { let _ = self.0.send(buf.to_vec()); Ok(buf.len()) } fn flush(&mut self) -> io::Result<()> { Ok(()) } } fn scope_io(stderr: Box) -> pi_uutils_ctx::ScopeIo { pi_uutils_ctx::ScopeIo { stdin: Box::new(io::empty()), stdin_fd: None, stdin_is_search_input: false, stdout: Box::new(io::sink()), stderr, cwd: PathBuf::from("."), env: HashMap::new(), cancel: Arc::new(AtomicBool::new(false)), } } fn run_in_scope(run: UutilRun, argv: Vec) -> (i32, String) { let (tx, rx) = flume::unbounded(); let code = pi_uutils_ctx::scope(scope_io(Box::new(ChanWriter(tx))), || run_caught(run, argv)); let mut err = Vec::new(); while let Ok(chunk) = rx.try_recv() { err.extend_from_slice(&chunk); } (code, String::from_utf8(err).expect("utf8 stderr")) } #[test] fn run_caught_passes_through_exit_code() { fn ok(_argv: Vec) -> i32 { 7 } let (code, err) = run_in_scope(ok, vec![OsString::from("wc")]); assert_eq!(code, 7, "successful utility exit code is preserved"); assert!(err.is_empty(), "no diagnostic on a clean run"); } #[test] fn run_caught_maps_panic_to_failure() { fn boom(_argv: Vec) -> i32 { panic!("kaboom"); } let (code, err) = run_in_scope(boom, vec![OsString::from("tail")]); assert_eq!(code, 1, "a panic in the utility becomes a failed command"); assert_eq!(err, "tail: internal error\n", "diagnostic names the command"); } }