brush_shell/bundled.rs
1//! Bundled commands: utilities that ship inside the brush binary.
2//!
3//! Utilities are shipped busybox-style (one binary, many names) but execute
4//! as a subprocess of brush so that shell redirections, pipes, and
5//! process-group state are honored by code that reads/writes the host
6//! process's standard fds (e.g., uutils crates).
7//!
8//! ## Protocol
9//!
10//! The brush binary recognizes a hidden first-position argument
11//! [`DISPATCH_FLAG`] followed by `<NAME> [ARGS...]`. When present, brush
12//! dispatches early in `main()` to the registered function for `NAME`, before
13//! any shell state is built, and exits with the function's return code. The
14//! dispatched function has the same signature as `uutils`' `uumain`:
15//! `fn(Vec<OsString>) -> i32`, with the bundled name as `argv[0]`.
16//!
17//! ## Shell integration
18//!
19//! For every entry in the registry, [`register_shims`] installs a brush
20//! builtin (using `register_builtin_if_unset`, so brush's own builtins always
21//! win on conflict). The builtin's execution path uses brush-core's existing
22//! external-command machinery to spawn `current_exe() <DISPATCH_FLAG> <name>
23//! <args...>`, inheriting the shell's redirection state for free.
24//!
25//! The mechanism is generic — the registry is just `name → fn pointer`. The
26//! `experimental-bundled-coreutils` feature populates it with uutils, but
27//! anything matching the signature can be registered.
28
29use std::collections::HashMap;
30use std::ffi::OsString;
31use std::io::Write;
32use std::path::PathBuf;
33use std::sync::OnceLock;
34
35use brush_core::ExecutionExitCode;
36use brush_core::builtins::{BoxFuture, ContentOptions, ContentType, Registration};
37use brush_core::commands::{self, CommandArg, ExecutionContext};
38use brush_core::extensions::ShellExtensions;
39
40/// The leading flag that signals a bundled-command dispatch.
41///
42/// Deliberately obscure so that it's unlikely to collide with future
43/// first-class shell flags or with scripts that happen to contain the
44/// literal token.
45pub const DISPATCH_FLAG: &str = "--invoke-bundled";
46
47/// Signature of a bundled command's entry point — matches `uu_*::uumain`.
48pub type BundledFn = fn(args: Vec<OsString>) -> i32;
49
50/// Process-wide registry. Set once at startup, read on each shim invocation
51/// (and during bundled-dispatch fast path).
52static REGISTRY: OnceLock<HashMap<String, BundledFn>> = OnceLock::new();
53
54/// Cached path to the running brush executable. Populated lazily on first
55/// shim invocation; left as `Err`-equivalent if `current_exe()` fails.
56static SELF_EXE: OnceLock<Option<PathBuf>> = OnceLock::new();
57
58/// Installs the bundled-command registry. Idempotent: only the first call
59/// takes effect.
60#[allow(
61 clippy::implicit_hasher,
62 reason = "registry uses the default hasher; callers build with HashMap::new()"
63)]
64pub fn install(commands: HashMap<String, BundledFn>) {
65 let _ = REGISTRY.set(commands);
66}
67
68/// Installs the registry from all compiled-in providers.
69///
70/// Providers are controlled by Cargo features. Binaries should call this
71/// once, before [`maybe_dispatch`], so both the dispatch fast path and the
72/// shell's shim builtins see a populated registry.
73pub fn install_default_providers() {
74 #[allow(unused_mut)]
75 let mut commands: HashMap<String, BundledFn> = HashMap::new();
76
77 #[cfg(feature = "experimental-bundled-coreutils")]
78 commands.extend(brush_coreutils_builtins::bundled_commands());
79
80 install(commands);
81}
82
83/// Returns the registered bundled commands, if [`install`] was called.
84#[must_use]
85pub fn registry() -> Option<&'static HashMap<String, BundledFn>> {
86 REGISTRY.get()
87}
88
89/// Runs the bundled-command fast path if the process was invoked for it.
90///
91/// If the process was invoked as `brush <DISPATCH_FLAG> <NAME> [ARGS...]`
92/// (with `<DISPATCH_FLAG>` as the very first argument after `argv[0]`), runs
93/// the registered function and returns its exit code as `Some(code)`. The
94/// caller is responsible for exiting the process with that code —
95/// centralizing the exit call in the binary's `main()` keeps destructors /
96/// panic hooks / tracing guards in the loop.
97///
98/// Returns `None` when the process was not invoked as a bundled dispatch, so
99/// normal shell startup can proceed.
100///
101/// The dispatch flag is only recognized in the leading position so that
102/// ordinary scripts and command lines containing the literal token elsewhere
103/// are not affected.
104#[must_use]
105pub fn maybe_dispatch() -> Option<i32> {
106 let mut raw = std::env::args_os();
107 let _argv0 = raw.next();
108 let first = raw.next()?;
109 if first != DISPATCH_FLAG {
110 return None;
111 }
112
113 // Everything after `DISPATCH_FLAG` belongs to the bundled command. The
114 // first such argument is the command name; subsequent arguments form its
115 // argv (with the name itself supplied as argv[0] to match the convention
116 // `uutils` and most CLI tools expect).
117 let rest: Vec<OsString> = raw.collect();
118 let Some((name, args)) = rest.split_first() else {
119 eprintln!("brush: {DISPATCH_FLAG} requires a command name");
120 return Some(exit_code(ExecutionExitCode::InvalidUsage));
121 };
122
123 // The registry is keyed by UTF-8 `String`, so a non-UTF-8 name can never
124 // match. Reject up front rather than allocating a lossy-substituted
125 // lookup key that could accidentally collide with a real registration.
126 let Some(name_str) = name.to_str() else {
127 eprintln!("brush: unknown bundled command: {}", name.to_string_lossy());
128 return Some(exit_code(ExecutionExitCode::NotFound));
129 };
130
131 let Some(func) = REGISTRY.get().and_then(|r| r.get(name_str)) else {
132 eprintln!("brush: unknown bundled command: {name_str}");
133 return Some(exit_code(ExecutionExitCode::NotFound));
134 };
135
136 let mut argv: Vec<OsString> = Vec::with_capacity(1 + args.len());
137 argv.push(name.clone());
138 argv.extend(args.iter().cloned());
139
140 Some(func(argv))
141}
142
143fn exit_code(code: ExecutionExitCode) -> i32 {
144 u8::from(code).into()
145}
146
147/// Returns the path to the running brush executable (cached).
148fn self_exe() -> Option<&'static PathBuf> {
149 SELF_EXE
150 .get_or_init(|| std::env::current_exe().ok())
151 .as_ref()
152}
153
154/// Help/usage content provider for the shim builtin. brush calls this for
155/// `help <name>`, `type <name>`, etc.
156#[allow(
157 clippy::needless_pass_by_value,
158 clippy::unnecessary_wraps,
159 reason = "signature dictated by brush_core::builtins::CommandContentFunc"
160)]
161fn shim_content(
162 name: &str,
163 content_type: ContentType,
164 _options: &ContentOptions,
165) -> Result<String, brush_core::Error> {
166 match content_type {
167 ContentType::ShortDescription => Ok(format!("{name} - bundled command")),
168 ContentType::DetailedHelp => Ok(format!(
169 "{name} - bundled command (executes via `brush {DISPATCH_FLAG} {name}`)\n"
170 )),
171 // A bundled command never contributes its own short-usage or man page
172 // through this path; detailed help comes from the bundled utility
173 // itself (`brush <DISPATCH_FLAG> <name> --help` or equivalent).
174 ContentType::ShortUsage | ContentType::ManPage => Ok(String::new()),
175 }
176}
177
178/// Builtin execute function shared by all bundled commands. Looks up the
179/// invoked name from `context.command_name` and re-executes the running
180/// brush binary as `brush <DISPATCH_FLAG> <name> <args>`.
181///
182/// Reuses the same entry point the `command` builtin uses (see
183/// `brush-builtins/src/command.rs`): constructs a [`commands::SimpleCommand`]
184/// whose `command_name` is the absolute brush exe path. Because that contains
185/// a path separator, `SimpleCommand::execute` routes directly to the
186/// external-execution path, bypassing the builtin/function lookup that would
187/// otherwise re-enter this very shim.
188///
189/// `use_functions = false` is defensive: even though the path-separator
190/// branch already skips function dispatch, we don't want a hypothetical
191/// refactor of `SimpleCommand` to silently break us.
192//
193// TODO(bundled): Process-group propagation.
194// The shim leaves `SimpleCommand::process_group_id` as `None`, so when a
195// bundled command appears in a pipeline it doesn't join the pipeline's
196// pgid — job control and pipeline-wide signal delivery misbehave.
197// `ExecutionContext` doesn't currently carry the dispatcher's pgid, so
198// fixing this requires plumbing the pgid through the builtin dispatch
199// boundary (likely as a field on `ExecutionParameters` or a new
200// `ExecutionContext` accessor).
201//
202// TODO(bundled): Pipeline serialization.
203// The builtin contract returns an `ExecutionResult` (a completed command),
204// not an `ExecutionSpawnResult` (a spawn handle), so this function has to
205// `.await` the child to completion before returning. That's fine for a
206// standalone bundled command or for the tail of a pipeline, but for a
207// bundled stage in the middle of `a | b | c` it means stage N only
208// "starts" (from brush's perspective) after its child has fully exited —
209// downstream stages get no parallelism with it. Fixing this means
210// bypassing the builtin API for bundled dispatch: either detect the shim
211// inside `SimpleCommand::execute`'s dispatch table and return an
212// `ExecutionSpawnResult::StartedProcess` directly (same shape as external
213// dispatch), or generalize the builtin API so a builtin can return a
214// spawn handle instead of a finished result.
215fn shim_execute<SE: ShellExtensions>(
216 context: ExecutionContext<'_, SE>,
217 args: Vec<CommandArg>,
218) -> BoxFuture<'_, Result<brush_core::ExecutionResult, brush_core::Error>> {
219 Box::pin(async move {
220 let exe_path = if let Some(p) = self_exe() {
221 p.to_string_lossy().into_owned()
222 } else {
223 let _ = writeln!(
224 context.stderr(),
225 "brush: cannot determine path to running executable"
226 );
227 return Ok(ExecutionExitCode::CannotExecute.into());
228 };
229
230 // Build the argv for the spawned brush. `SimpleCommand::args[0]` is
231 // dropped by the external-execution path (argv[0] of the spawned
232 // process comes from `cmd.argv0` below), so a placeholder suffices;
233 // args[1..] become the spawned process's argv[1..]. The caller's
234 // `args[0]` is the bundled name by builtin-dispatch convention — we
235 // replace it with an explicit `<name>` after `DISPATCH_FLAG` so the
236 // child's dispatcher sees it in a fixed slot.
237 let bundled_name = context.command_name.clone();
238 let mut child_args: Vec<CommandArg> = Vec::with_capacity(args.len() + 2);
239 child_args.push(CommandArg::String(String::new())); // args[0], dropped
240 child_args.push(CommandArg::String(DISPATCH_FLAG.into()));
241 child_args.push(CommandArg::String(bundled_name.clone()));
242 child_args.extend(args.into_iter().skip(1));
243
244 let mut cmd = commands::SimpleCommand::new(
245 commands::ShellForCommand::ParentShell(context.shell),
246 context.params,
247 exe_path,
248 child_args,
249 );
250 cmd.use_functions = false;
251 // Override the spawned process's argv[0] so tools that report errors
252 // via their own argv[0] (uutils' `uucore::util_name()` reads
253 // `std::env::args_os()[0]` into a LazyLock at first use) render as
254 // `<name>:` rather than `brush:`. Without this the child sees the
255 // brush exe path as argv[0] and misattributes errors.
256 cmd.argv0 = Some(bundled_name);
257
258 let spawn_result = cmd.execute().await?;
259 let wait_result = spawn_result.wait().await?;
260 Ok(wait_result.into())
261 })
262}
263
264/// Constructs a [`Registration`] for the bundled-shim builtin. The same
265/// registration value can be reused for every bundled name; per-name
266/// dispatch happens via `context.command_name` at execution time.
267fn shim_registration<SE: ShellExtensions>() -> Registration<SE> {
268 Registration {
269 execute_func: shim_execute::<SE>,
270 content_func: shim_content,
271 disabled: false,
272 special_builtin: false,
273 declaration_builtin: false,
274 }
275}
276
277/// Registers a shim builtin for every name in the installed bundled-command
278/// registry.
279///
280/// Uses `register_builtin_if_unset` so brush's own builtins (echo, printf,
281/// true, false, etc.) win on conflict.
282pub fn register_shims<SE: ShellExtensions>(shell: &mut brush_core::Shell<SE>) {
283 let Some(registry) = REGISTRY.get() else {
284 return;
285 };
286 for name in registry.keys() {
287 shell.register_builtin_if_unset(name.clone(), shim_registration::<SE>());
288 }
289}