diff --git a/NUCLEIC_FORK.md b/NUCLEIC_FORK.md index 78d88bc..60bc0b0 100644 --- a/NUCLEIC_FORK.md +++ b/NUCLEIC_FORK.md @@ -14,6 +14,10 @@ Every fork change is marked with a `// nash:` comment. Current patches: | `brush-core/src/expansion.rs` | corpus divergence D1: added `ExpansionPiece::LiteralText` — literal unquoted text is never field-split (bash splits only expansion results) but keeps glob characters active. Parameter-expansion substitutions (`${v:-word}` etc.) convert back to splittable at the expansion boundary; `ExpanderOptions.field_split_literal_text` lets data-string callers opt in. Upstream candidate. | | `brush-core/src/completion.rs` | `compgen -W` word list opts into `field_split_literal_text` (the -W string is data) | | `brush-shell/tests/cases/compat/ifs.yaml` | removed `known_failure` from 3 IFS tests the D1 fix repairs | +| `brush-core/src/gate.rs` (new) + `lib.rs` | the `Gate` trait (docs/NASH.md §4.2): process-global, verdict-shaped hook; allow-all default so an unconfigured shell behaves exactly like upstream | +| `brush-core/src/commands.rs` | `SimpleCommand::execute` split into gate wrapper + `execute_inner`: `on_exec` before dispatch (Deny short-circuits), completion correlated through all three spawn-result variants | +| `brush-core/src/processes.rs` | `ChildProcess.gate_token` + `on_exit` reporting from `wait()`/`poll()` (128+signal for signal deaths) | +| `brush-shell/src/entry.rs` | `set_exit_hook` seam so nash can flush its event buffer before `process::exit` | Verification (this container): brush compat suite `2067 ran: 1684 succeeded, 6 failed, 377 known to fail, 38 skipped` — the 6 failures are identical to a @@ -22,8 +26,8 @@ pristine `brush-shell-v0.4.0` baseline run (environmental: ANSI-C quote and zero fork-caused regressions; +3 successes vs baseline are the repaired IFS tests. nash corpus: 94/94. -Planned (M1, per docs/NASH.md §4.2): `brush-core` `Gate` trait (verdict-shaped -observation hooks) — the only substantial divergence from upstream. +Planned (M2, per docs/NASH.md §5.2–5.3): redirect/pipe tap hooks on the `Gate` +trait (`on_redirect`/`on_pipe`) in `interp.rs`/`openfiles.rs`. Updating: diff against the upstream tag, re-vendor, re-apply `// nash:` patches (grep for the marker), then re-run `shell/corpus/replay.py` and the brush compat diff --git a/brush-core/src/commands.rs b/brush-core/src/commands.rs index 077c4ac..99af699 100644 --- a/brush-core/src/commands.rs +++ b/brush-core/src/commands.rs @@ -348,11 +348,76 @@ impl<'a, SE: extensions::ShellExtensions> SimpleCommand<'a, SE> { /// The command may be a builtin, a shell function, or an externally /// executed command. This function's implementation is responsible for /// dispatching it appropriately according to the context provided. + /// + /// nash: this is the gate choke point (docs/NASH.md §4.2). Every simple + /// command passes through the process-global [`crate::gate::Gate`] before + /// dispatch; completion is reported back with the issued token, however the + /// command runs (inline, spawned process, or spawned task). + pub async fn execute(self) -> Result { + use std::io::Write; + + let gate = crate::gate::gate(); + + let mut argv: Vec = self.args.iter().map(ToString::to_string).collect(); + if argv.is_empty() { + argv.push(self.command_name.clone()); + } + let start_event = crate::gate::ExecStart { + argv, + cwd: self.shell.working_dir().to_path_buf(), + }; + let token = match gate.on_exec(start_event) { + crate::gate::Verdict::Allow(token) => token, + crate::gate::Verdict::Deny { status, message } => { + let mut stderr = self.params.stderr(&self.shell); + let _ = writeln!(stderr, "{}: {message}", self.command_name); + return Ok(ExecutionResult::new(status).into()); + } + }; + + match self.execute_inner().await { + Ok(ExecutionSpawnResult::Completed(result)) => { + gate.on_exit( + token, + crate::gate::ExecEnd { + exit_code: i32::from(u8::from(&result.exit_code)), + }, + ); + Ok(ExecutionSpawnResult::Completed(result)) + } + Ok(ExecutionSpawnResult::StartedProcess(mut child)) => { + child.gate_token = Some(token); + Ok(ExecutionSpawnResult::StartedProcess(child)) + } + Ok(ExecutionSpawnResult::StartedTask(handle)) => { + let wrapped = tokio::task::spawn(async move { + let result = handle.await??; + crate::gate::gate().on_exit( + token, + crate::gate::ExecEnd { + exit_code: i32::from(u8::from(&result.exit_code)), + }, + ); + Ok(result) + }); + Ok(ExecutionSpawnResult::StartedTask(wrapped)) + } + Err(err) => { + let exit_code = match err.kind() { + error::ErrorKind::CommandNotFound(_) => 127, + _ => 1, + }; + gate.on_exit(token, crate::gate::ExecEnd { exit_code }); + Err(err) + } + } + } + #[allow( clippy::missing_panics_doc, reason = "these unwrap calls should not panic" )] - pub async fn execute(mut self) -> Result { + async fn execute_inner(mut self) -> Result { // First see if it's the name of a builtin. let builtin = self.shell.builtins().get(&self.command_name).cloned(); diff --git a/brush-core/src/gate.rs b/brush-core/src/gate.rs new file mode 100644 index 0000000..35dd4af --- /dev/null +++ b/brush-core/src/gate.rs @@ -0,0 +1,75 @@ +//! nash: the observation/enforcement gate (docs/NASH.md §4.2). +//! +//! A process-global, verdict-shaped hook at the shell's command choke point. +//! The default implementation allows everything and observes nothing, so an +//! unconfigured shell behaves exactly like upstream brush. nash installs a +//! recording gate at startup (`nash-observe`); a future enforcement mode may +//! return `Deny` (and, later, hold pending a host policy round-trip) without +//! any further changes to this crate. + +use std::path::PathBuf; +use std::sync::OnceLock; + +/// Details about a simple command about to be executed (builtin, function, or +/// external), captured after expansion. +#[derive(Clone, Debug)] +pub struct ExecStart { + /// Full argv, argv[0] first. + pub argv: Vec, + /// The shell's working directory at execution time. + pub cwd: PathBuf, +} + +/// Details about a completed simple command. +#[derive(Clone, Debug)] +pub struct ExecEnd { + /// Exit code (128+signal for signal deaths). + pub exit_code: i32, +} + +/// The gate's decision for a command about to execute. +pub enum Verdict { + /// Run the command; the token is echoed back via `on_exit`. + Allow(u64), + /// Refuse to run the command; the shell reports `message` on stderr and + /// the command evaluates to `status`. + Deny { + /// Exit status the denied command evaluates to. + status: u8, + /// Message reported to stderr. + message: String, + }, +} + +/// Process-global hook invoked around every simple command. +pub trait Gate: Send + Sync { + /// Called before a simple command runs. Must not block in observe-only + /// implementations. + fn on_exec(&self, ev: ExecStart) -> Verdict; + /// Called when a simple command completes, with the token issued by + /// `on_exec`. + fn on_exit(&self, token: u64, ev: ExecEnd); +} + +struct AllowAll; + +impl Gate for AllowAll { + fn on_exec(&self, _ev: ExecStart) -> Verdict { + Verdict::Allow(0) + } + fn on_exit(&self, _token: u64, _ev: ExecEnd) {} +} + +static GATE: OnceLock> = OnceLock::new(); +static ALLOW_ALL: AllowAll = AllowAll; + +/// Installs the process-global gate. May be called at most once, before any +/// shell runs; later calls are ignored. +pub fn set_gate(gate: Box) { + let _ = GATE.set(gate); +} + +/// Returns the installed gate, or the allow-all default. +pub(crate) fn gate() -> &'static dyn Gate { + GATE.get().map_or(&ALLOW_ALL as &dyn Gate, AsRef::as_ref) +} diff --git a/brush-core/src/lib.rs b/brush-core/src/lib.rs index ee0cdfa..2708c82 100644 --- a/brush-core/src/lib.rs +++ b/brush-core/src/lib.rs @@ -14,6 +14,8 @@ pub mod expansion; mod extendedtests; pub mod extensions; pub mod functions; +// nash: observation/enforcement gate (docs/NASH.md §4.2). +pub mod gate; pub mod history; pub mod int_utils; pub mod interfaces; diff --git a/brush-core/src/processes.rs b/brush-core/src/processes.rs index 4b55356..6409b9a 100644 --- a/brush-core/src/processes.rs +++ b/brush-core/src/processes.rs @@ -17,6 +17,9 @@ pub struct ChildProcess { pid: Option, /// If available, the process group ID of the child. pgid: Option, + // nash: gate token issued by `Gate::on_exec` for this command, reported + // back via `Gate::on_exit` when the process completes. + pub(crate) gate_token: Option, } impl ChildProcess { @@ -30,6 +33,7 @@ impl ChildProcess { exec_future: Box::pin(child.wait_with_output()), pid, pgid, + gate_token: None, } } @@ -54,7 +58,15 @@ impl ChildProcess { loop { tokio::select! { output = &mut self.exec_future => { - break Ok(ProcessWaitResult::Completed(output?)) + let output = output?; + // nash: report process completion to the gate exactly once. + if let Some(token) = self.gate_token.take() { + crate::gate::gate().on_exit( + token, + crate::gate::ExecEnd { exit_code: exit_code_of(&output.status) }, + ); + } + break Ok(ProcessWaitResult::Completed(output)) }, _ = sigtstp.recv() => { break Ok(ProcessWaitResult::Stopped) @@ -75,12 +87,39 @@ impl ChildProcess { pub(crate) fn poll(&mut self) -> Option> { let checkable_future = &mut self.exec_future; - checkable_future + let result: Option> = checkable_future .now_or_never() - .map(|result| result.map_err(Into::into)) + .map(|result| result.map_err(Into::into)); + // nash: completion can also be observed via poll (e.g. job checks). + if let Some(Ok(output)) = &result { + if let Some(token) = self.gate_token.take() { + crate::gate::gate().on_exit( + token, + crate::gate::ExecEnd { + exit_code: exit_code_of(&output.status), + }, + ); + } + } + result } } +// nash: numeric exit code for gate reporting (128+signal for signal deaths). +fn exit_code_of(status: &std::process::ExitStatus) -> i32 { + if let Some(code) = status.code() { + return code; + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return 128 + signal; + } + } + 1 +} + /// Represents the result of waiting for an executing process. pub enum ProcessWaitResult { /// The process completed. diff --git a/brush-shell/src/entry.rs b/brush-shell/src/entry.rs index 946fbb0..8c5cb2a 100644 --- a/brush-shell/src/entry.rs +++ b/brush-shell/src/entry.rs @@ -193,9 +193,26 @@ pub fn run() { } }; + run_exit_hook(); std::process::exit(i32::from(exit_code)); } +// nash: a hook invoked right before the process exits, so an embedding shell +// (nash) can flush its observation event buffer (docs/NASH.md §6.1). +static EXIT_HOOK: std::sync::OnceLock> = std::sync::OnceLock::new(); + +/// nash: registers a hook run immediately before the shell process exits. +/// May be called at most once; later calls are ignored. +pub fn set_exit_hook(hook: Box) { + let _ = EXIT_HOOK.set(hook); +} + +fn run_exit_hook() { + if let Some(hook) = EXIT_HOOK.get() { + hook(); + } +} + /// Installs panic handlers to report our panic and cleanly exit on panic. fn install_panic_handlers() { //