12591dbde3
- Introduced `StreamWriter` with configurable block and line buffering policies across builtins. - Updated pipeline stages and compound commands to execute concurrently as tasks. - Replaced Linux splice and local stream wrapping with generic `Write` streams and explicit flushing. - Added regression and streaming smoke tests to verify concurrent output and prevent deadlocks.
1293 lines
43 KiB
Rust
1293 lines
43 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, BufWriter, LineWriter, Read, Write},
|
|
marker::PhantomData,
|
|
panic::{AssertUnwindSafe, catch_unwind},
|
|
path::{Path, PathBuf},
|
|
time::Duration,
|
|
sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, Ordering},
|
|
},
|
|
};
|
|
|
|
use parking_lot::Mutex;
|
|
|
|
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. Raw: utilities
|
|
/// with bulk output buffer it themselves via [`Host::stdout_writer`].
|
|
pub stdout: OpenFile,
|
|
/// Standard error, buffered with the destination-aware policy of
|
|
/// [`StreamWriter`]; the null device when fd 2 is closed. When fd 2 shares
|
|
/// fd 1's destination (`2>&1`, or the default capture pipe), this is the
|
|
/// same serialized writer [`Host::stdout_writer`] returns, so interleaving
|
|
/// follows write order exactly.
|
|
pub stderr: StreamWriter,
|
|
|
|
name: String,
|
|
cwd: PathBuf,
|
|
env: HashMap<String, String>,
|
|
cancel: Arc<AtomicBool>,
|
|
exit_code: i32,
|
|
stdin_is_search_input: bool,
|
|
/// The shared stdout/stderr writer when both fds point at one
|
|
/// destination; `None` when they diverge.
|
|
merged_out: Option<Arc<Mutex<StreamWriter>>>,
|
|
}
|
|
|
|
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 as a raw [`OpenFile`], for utilities that hand a
|
|
/// writer to a helper thread. Data pending in the buffered stderr (at most
|
|
/// one partial line) is not carried over.
|
|
pub fn stderr_clone(&self) -> OpenFile {
|
|
self.stderr.dup_file()
|
|
}
|
|
|
|
/// A buffered stdout with a flush policy chosen by the destination of
|
|
/// fd 1; see [`StdoutWriter`].
|
|
///
|
|
/// Utilities that emit output progressively — stream filters (`grep`,
|
|
/// `sed`, `cut`) and directory walkers (`ls`, `fd`) — must write through
|
|
/// this rather than a raw `BufWriter`, so their output is visible as it is
|
|
/// produced. Batch emitters whose output only exists once all input is
|
|
/// consumed (`sort`, `tac`, `seq`) may keep plain block buffering.
|
|
pub fn stdout_writer(&self) -> StreamWriter {
|
|
match &self.merged_out {
|
|
Some(shared) => StreamWriter::Shared(Arc::clone(shared)),
|
|
None => StreamWriter::new(self.stdout.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.dup_file(),
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
/// Buffered writer for a utility's output streams, with a flush policy
|
|
/// matching the destination.
|
|
///
|
|
/// When the destination is a regular file (or the null device), nothing
|
|
/// observes the output until the utility exits, so writes are block-buffered
|
|
/// for throughput. Everywhere else — a pipe to the next pipeline stage, the
|
|
/// harness capture pipe behind the TUI's live tool output (a pipe fd wrapped
|
|
/// in `OpenFile::File`, hence the `fstat` in [`is_regular_file`] rather than
|
|
/// a variant match), or an in-memory stream — writes are line-buffered so
|
|
/// each completed line is visible as soon as it is produced rather than when
|
|
/// the utility exits.
|
|
///
|
|
/// Construct via [`Host::stdout_writer`]; [`StreamWriter::line`] and
|
|
/// [`StreamWriter::block`] force a policy for utilities with explicit
|
|
/// buffering flags (`rg --line-buffered`).
|
|
pub(crate) enum StreamWriter {
|
|
/// Block-buffered: flushed when full, on drop, and on explicit `flush`.
|
|
Block(BufWriter<OpenFile>),
|
|
/// Line-buffered: additionally flushed through the last newline of every
|
|
/// write.
|
|
Line(LineWriter<OpenFile>),
|
|
/// A serialized handle onto a writer shared by stdout and stderr, used
|
|
/// when fd 1 and fd 2 have the same destination (`2>&1`, or the default
|
|
/// capture pipe): one buffer means diagnostics and output interleave in
|
|
/// exactly the order they were written.
|
|
Shared(Arc<Mutex<StreamWriter>>),
|
|
}
|
|
|
|
impl StreamWriter {
|
|
const BLOCK_CAPACITY: usize = 64 * 1024;
|
|
const LINE_CAPACITY: usize = 16 * 1024;
|
|
|
|
/// Picks the policy for `file`: block for regular files, line otherwise.
|
|
pub fn new(file: OpenFile) -> Self {
|
|
if is_regular_file(&file) { Self::block(file) } else { Self::line(file) }
|
|
}
|
|
|
|
/// Forces line buffering regardless of destination.
|
|
pub fn line(file: OpenFile) -> Self {
|
|
Self::Line(LineWriter::with_capacity(Self::LINE_CAPACITY, file))
|
|
}
|
|
|
|
/// Forces block buffering regardless of destination.
|
|
pub fn block(file: OpenFile) -> Self {
|
|
Self::Block(BufWriter::with_capacity(Self::BLOCK_CAPACITY, file))
|
|
}
|
|
|
|
/// Duplicates the underlying descriptor as a raw [`OpenFile`], for
|
|
/// utilities that hand a writer to helper threads. Buffered data pending
|
|
/// in this writer (at most one partial line under the line policy) is not
|
|
/// carried over.
|
|
pub fn dup_file(&self) -> OpenFile {
|
|
match self {
|
|
Self::Block(w) => w.get_ref().clone(),
|
|
Self::Line(w) => w.get_ref().clone(),
|
|
Self::Shared(shared) => shared.lock().dup_file(),
|
|
}
|
|
}
|
|
|
|
/// Whether the destination is a terminal, mirroring
|
|
/// [`OpenFile::is_terminal`].
|
|
pub fn is_terminal(&self) -> bool {
|
|
match self {
|
|
Self::Block(w) => w.get_ref().is_terminal(),
|
|
Self::Line(w) => w.get_ref().is_terminal(),
|
|
Self::Shared(shared) => shared.lock().is_terminal(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Write for StreamWriter {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
match self {
|
|
Self::Block(w) => w.write(buf),
|
|
Self::Line(w) => w.write(buf),
|
|
Self::Shared(shared) => shared.lock().write(buf),
|
|
}
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
match self {
|
|
Self::Block(w) => w.flush(),
|
|
Self::Line(w) => w.flush(),
|
|
Self::Shared(shared) => shared.lock().flush(),
|
|
}
|
|
}
|
|
|
|
fn write_vectored(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<usize> {
|
|
match self {
|
|
Self::Block(w) => w.write_vectored(bufs),
|
|
Self::Line(w) => w.write_vectored(bufs),
|
|
Self::Shared(shared) => shared.lock().write_vectored(bufs),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether writes to `file` land in a regular file, where output is only ever
|
|
/// observed after the utility exits.
|
|
///
|
|
/// A pipe wrapped in `std::fs::File` (how the shell hands the capture pipe to
|
|
/// a command) reports a fifo file type, and `metadata` on exotic handles can
|
|
/// fail outright; both classify as "not a regular file" and get line
|
|
/// buffering, the visibility-safe default.
|
|
pub(crate) fn is_regular_file(file: &OpenFile) -> bool {
|
|
match file {
|
|
OpenFile::File(f) => f.metadata().is_ok_and(|m| m.is_file()),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Whether two open files refer to the same non-seekable destination — the
|
|
/// `2>&1` case (and the harness default, where one capture pipe backs both
|
|
/// fds).
|
|
///
|
|
/// Matching is by `fstat` device+inode and deliberately excludes regular
|
|
/// files: `cmd >f 2>f` opens two descriptions with independent offsets, and
|
|
/// funneling them through one writer would change where the bytes land.
|
|
/// Pipes, fifos, terminals, and sockets have no offset, so a device+inode
|
|
/// match identifies the same object.
|
|
#[cfg(unix)]
|
|
fn same_destination(a: &OpenFile, b: &OpenFile) -> bool {
|
|
use std::os::unix::fs::MetadataExt;
|
|
fn id(file: &OpenFile) -> Option<(u64, u64)> {
|
|
let fd = file.try_borrow_as_fd().ok()?;
|
|
let dup = fd.try_clone_to_owned().ok()?;
|
|
let meta = std::fs::File::from(dup).metadata().ok()?;
|
|
if meta.file_type().is_file() {
|
|
return None;
|
|
}
|
|
Some((meta.dev(), meta.ino()))
|
|
}
|
|
match (id(a), id(b)) {
|
|
(Some(a), Some(b)) => a == b,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn same_destination(_a: &OpenFile, _b: &OpenFile) -> bool {
|
|
false
|
|
}
|
|
|
|
/// 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.
|
|
pub(crate) 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));
|
|
|
|
let stdout = or_null(context.try_fd(OpenFiles::STDOUT_FD))?;
|
|
let stderr_file = or_null(context.try_fd(OpenFiles::STDERR_FD))?;
|
|
// `2>&1` (and the default capture pipe): one shared writer keeps
|
|
// diagnostics and output in exact write order.
|
|
let (merged_out, stderr) = if same_destination(&stdout, &stderr_file) {
|
|
let shared = Arc::new(Mutex::new(StreamWriter::new(stderr_file)));
|
|
(Some(Arc::clone(&shared)), StreamWriter::Shared(shared))
|
|
} else {
|
|
(None, StreamWriter::new(stderr_file))
|
|
};
|
|
|
|
Ok(Host {
|
|
stdin: Stdin {
|
|
file: or_null(stdin)?,
|
|
fd: stdin_fd,
|
|
cancel: Arc::clone(&cancel),
|
|
},
|
|
stdout,
|
|
stderr,
|
|
name: invoked,
|
|
cwd: context.shell.working_dir().to_path_buf(),
|
|
env,
|
|
cancel,
|
|
exit_code: 0,
|
|
stdin_is_search_input,
|
|
merged_out,
|
|
})
|
|
}
|
|
|
|
/// 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, StreamWriter,
|
|
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()
|
|
}
|
|
|
|
/// Shared stdout buffer for tests that must observe output mid-run.
|
|
pub(crate) fn stdout_buffer(&self) -> Arc<Mutex<Vec<u8>>> {
|
|
Arc::clone(&self.stdout)
|
|
}
|
|
|
|
/// 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) {
|
|
Self::for_test_with_stdin(
|
|
name,
|
|
Box::new(MemStream::reader(stdin.into())),
|
|
cwd,
|
|
)
|
|
}
|
|
|
|
/// Builds a host backed by an arbitrary in-memory stdin stream.
|
|
pub(crate) fn for_test_with_stdin(
|
|
name: &str,
|
|
stdin: Box<dyn openfiles::Stream>,
|
|
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(stdin),
|
|
fd: None,
|
|
cancel: Arc::clone(&cancel),
|
|
},
|
|
stdout: OpenFile::Stream(Box::new(MemStream::writer(Arc::clone(
|
|
&capture.stdout,
|
|
)))),
|
|
stderr: StreamWriter::new(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,
|
|
merged_out: None,
|
|
};
|
|
(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())
|
|
}
|
|
}
|
|
|
|
mod stdout_policy {
|
|
use parking_lot::Mutex;
|
|
|
|
use super::MemStream;
|
|
use crate::host::{Arc, OpenFile, StreamWriter, Write};
|
|
|
|
/// Contract: on a non-file destination, a completed line is visible to
|
|
/// the consumer before any explicit flush; a partial line is held back.
|
|
#[test]
|
|
fn line_policy_flushes_completed_lines_immediately() {
|
|
let buf = Arc::new(Mutex::new(Vec::new()));
|
|
let stream = OpenFile::Stream(Box::new(MemStream::writer(Arc::clone(&buf))));
|
|
let mut out = StreamWriter::new(stream);
|
|
assert!(matches!(out, StreamWriter::Line(_)));
|
|
|
|
out.write_all(b"hit\n").unwrap();
|
|
assert_eq!(buf.lock().as_slice(), b"hit\n");
|
|
|
|
out.write_all(b"partial").unwrap();
|
|
assert_eq!(buf.lock().as_slice(), b"hit\n");
|
|
|
|
out.flush().unwrap();
|
|
assert_eq!(buf.lock().as_slice(), b"hit\npartial");
|
|
}
|
|
|
|
/// Contract: a regular-file destination stays block-buffered — bytes
|
|
/// reach the file only on flush, not per line.
|
|
#[test]
|
|
fn regular_file_gets_block_buffering() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("out.txt");
|
|
let file = std::fs::File::create(&path).unwrap();
|
|
let mut out = StreamWriter::new(OpenFile::File(file));
|
|
assert!(matches!(out, StreamWriter::Block(_)));
|
|
|
|
out.write_all(b"hit\n").unwrap();
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"");
|
|
|
|
out.flush().unwrap();
|
|
assert_eq!(std::fs::read(&path).unwrap(), b"hit\n");
|
|
}
|
|
|
|
/// Contract: the shell hands commands their stdout as a pipe fd wrapped
|
|
/// in `std::fs::File`; that must classify as line-buffered, or live tool
|
|
/// output stalls until the utility exits.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn pipe_wrapped_as_file_gets_line_buffering() {
|
|
let (reader, writer) = std::io::pipe().unwrap();
|
|
let file = std::fs::File::from(std::os::fd::OwnedFd::from(writer));
|
|
assert!(matches!(StreamWriter::new(OpenFile::File(file)), StreamWriter::Line(_)));
|
|
drop(reader);
|
|
}
|
|
|
|
/// Contract for `2>&1`: two handles onto one shared writer interleave
|
|
/// in exact write order — diagnostics land where they were emitted
|
|
/// relative to output, not where a second buffer happened to flush.
|
|
#[test]
|
|
fn shared_handles_preserve_write_order() {
|
|
let buf = Arc::new(Mutex::new(Vec::new()));
|
|
let inner =
|
|
StreamWriter::line(OpenFile::Stream(Box::new(MemStream::writer(Arc::clone(&buf)))));
|
|
let shared = Arc::new(Mutex::new(inner));
|
|
let mut out = StreamWriter::Shared(Arc::clone(&shared));
|
|
let mut err = StreamWriter::Shared(shared);
|
|
|
|
writeln!(out, "out 1").unwrap();
|
|
writeln!(err, "err 1").unwrap();
|
|
writeln!(out, "out 2").unwrap();
|
|
|
|
assert_eq!(buf.lock().as_slice(), b"out 1\nerr 1\nout 2\n");
|
|
}
|
|
|
|
/// Contract: `2>&1` over a pipe is detected (same object, no offset),
|
|
/// while distinct pipes and regular files — which have independent
|
|
/// offsets under `>f 2>f` — are not merged.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn same_destination_detects_dup_pipes_only() {
|
|
use crate::host::same_destination;
|
|
|
|
let (reader, writer) = std::io::pipe().unwrap();
|
|
let dup = writer.try_clone().unwrap();
|
|
let a = OpenFile::File(std::fs::File::from(std::os::fd::OwnedFd::from(writer)));
|
|
let b = OpenFile::File(std::fs::File::from(std::os::fd::OwnedFd::from(dup)));
|
|
assert!(same_destination(&a, &b));
|
|
|
|
let (reader2, writer2) = std::io::pipe().unwrap();
|
|
let c = OpenFile::File(std::fs::File::from(std::os::fd::OwnedFd::from(writer2)));
|
|
assert!(!same_destination(&a, &c));
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("out.txt");
|
|
let f1 = OpenFile::File(std::fs::File::create(&path).unwrap());
|
|
let f2 = OpenFile::File(std::fs::File::create(&path).unwrap());
|
|
assert!(!same_destination(&f1, &f2));
|
|
|
|
drop((reader, reader2));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[allow(unused_imports, reason = "used by utility test modules, which are feature-gated")]
|
|
pub(crate) use testing::{Capture, run_util};
|