From 383b182f904ae89f3278be11c6a74dc1f3a5086e Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Wed, 7 Oct 2026 06:20:03 +0000 Subject: [PATCH] feat(builtins): add 21 missing everyday commands, fix N>file redirects New: arch, sum, shasum, egrep, fgrep, link, unlink, chgrp, nohup, nice, flock, getconf, tty, sync, hostid, groups, logname, users, who, uptime, free. /bin/bash and /bin/sh now exist as rootfs stubs, and type/command -V report interpreter-dispatched builtins (builtin, command, let, ...). Fix: `cmd N>file` (N>=3) sent stdout into the file. It now opens fd N only, so `( flock -n 9 && ... ) 9>lock` and `{ echo x >&3; } 3>f` behave like bash. Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA --- .../__test__/runtime-compat/security.test.mjs | 2 +- crates/bashkit-js/__test__/security.spec.ts | 5 +- crates/bashkit/docs/compatibility.md | 16 + crates/bashkit/docs/threat-model.md | 2 +- crates/bashkit/src/builtins/checksum.rs | 11 +- .../bashkit/src/builtins/coreutils_extra.rs | 344 ++++++++ crates/bashkit/src/builtins/mod.rs | 4 + crates/bashkit/src/builtins/sysextra.rs | 749 ++++++++++++++++++ crates/bashkit/src/interpreter/mod.rs | 80 +- crates/bashkit/src/interpreter/redirection.rs | 16 +- .../integration/limitations_evidence_tests.rs | 12 +- .../tests/integration/threat_model_tests.rs | 17 +- crates/bashkit/tests/integration/tty_tests.rs | 12 + .../spec_cases/bash/exec-fd-redirect.test.sh | 42 + .../spec_cases/bash/system-commands.test.sh | 202 +++++ knowledge/foundations/vfs.md | 2 +- knowledge/log.md | 2 + knowledge/operations/limitations.md | 2 +- knowledge/security/threat-model.md | 2 +- knowledge/status/builtins.json | 86 +- 20 files changed, 1589 insertions(+), 19 deletions(-) create mode 100644 crates/bashkit/src/builtins/coreutils_extra.rs create mode 100644 crates/bashkit/src/builtins/sysextra.rs create mode 100644 crates/bashkit/tests/spec_cases/bash/system-commands.test.sh diff --git a/crates/bashkit-js/__test__/runtime-compat/security.test.mjs b/crates/bashkit-js/__test__/runtime-compat/security.test.mjs index 9305942c4..d3eb07d3b 100644 --- a/crates/bashkit-js/__test__/runtime-compat/security.test.mjs +++ b/crates/bashkit-js/__test__/runtime-compat/security.test.mjs @@ -48,7 +48,7 @@ describe("security", () => { it("sandbox escape blocked", () => { const bash = new Bash(); - assert.notEqual(bash.executeSync("exec /bin/bash").exitCode, 0); + assert.notEqual(bash.executeSync("exec /usr/bin/gcc").exitCode, 0); assert.notEqual(bash.executeSync("cat /proc/self/maps 2>&1").exitCode, 0); assert.ok(!bash.executeSync("cat /etc/passwd 2>&1").stdout.includes("root:x:0:0")); assert.notEqual( diff --git a/crates/bashkit-js/__test__/security.spec.ts b/crates/bashkit-js/__test__/security.spec.ts index ea37722da..b12f8a940 100644 --- a/crates/bashkit-js/__test__/security.spec.ts +++ b/crates/bashkit-js/__test__/security.spec.ts @@ -221,8 +221,11 @@ test("WB: stderr truncation on massive error output", (t) => { test("WB: exec cannot escape sandbox (TM-ESC-001)", (t) => { const bash = new Bash(); // exec runs commands within VFS sandbox — external binaries don't exist - const r = bash.executeSync("exec /bin/bash"); + const r = bash.executeSync("exec /usr/bin/gcc"); t.not(r.exitCode, 0, "exec of external binary must fail in sandbox"); + // /bin/bash is a rootfs stub that re-enters the in-process interpreter + const inner = bash.executeSync("exec /bin/bash -c 'cat /etc/passwd'"); + t.false(inner.stdout.includes("root:x:0:0"), "host passwd must not leak"); }); test("WB: /proc filesystem not accessible (TM-ESC-003)", (t) => { diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index eb5b265f1..91f8ba13c 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -191,6 +191,21 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and | `mktemp` | `-d`, `-p`, `-t` | Create temporary files | | `realpath` | `PATH` | Resolve path | | `pushd`/`popd`/`dirs` | standard flags | Directory stack | +| `arch` | (none) | Virtual machine name (`x86_64`) | +| `sum` | `-r`, `-s` | BSD and System V checksums, GNU output format | +| `shasum` | `-a 1/224/256/384/512` | Perl `shasum` front end over the `sha*sum` builtins | +| `egrep`/`fgrep` | `grep` flags | `grep -E` / `grep -F` | +| `link`/`unlink` | `FILE1 FILE2` / `FILE` | Single-file link (a symlink, L-FS-001) and remove | +| `chgrp` | `GROUP FILE...` | Change group (virtual, like `chown`) | +| `nohup`/`nice` | `COMMAND...`, `nice -n N` | Run the command; no signal or priority effect in a sandbox | +| `flock` | `FILE CMD`, `FILE -c CMD`, `-n FD` | Creates the lock file and runs the command; locks are uncontended in one session | +| `getconf` | `NAME`, `-a` | POSIX config values, consistent with `nproc` and `/proc/meminfo` | +| `tty` | `-s` | "not a tty" unless the embedder sets `tty(0, true)`, like `[ -t 0 ]` | +| `sync`/`hostid` | (none) | No-op / fixed virtual host id | +| `groups`/`logname` | `[USER]` | Virtual user only | +| `users`/`who` | (none) | Empty: no login sessions | +| `uptime` | `-p`, `-s` | Derived from the virtual clock | +| `free` | `-b`, `-k`, `-m`, `-g`, `-h`, `-t` | Fixed virtual memory, consistent with `/proc/meminfo` | ### Not Implemented @@ -225,6 +240,7 @@ Chrono's validated strftime implementation, plus GNU `%N`, `%3N`, `%6N`, and | `2>` | ✅ | `cmd 2> file` | Stderr redirect | | `2>&1` | ✅ | `cmd 2>&1` | Stderr to stdout | | `&>` | ✅ | `cmd &> file` | Both to file | +| `N>`/`N>>` (N≥3) | ✅ | `{ cmd >&3; } 3>file` | Opens fd N only; stdout untouched | ### Control Flow diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index be1fb24af..164f2f82f 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -207,7 +207,7 @@ Scripts may attempt to break out of the sandbox to access the host system. | Threat | Attack Example | Mitigation | Status | |--------|---------------|------------|--------| -| Shell escape (TM-ESC-005) | `exec /bin/bash` | Not implemented (exit 127) | MITIGATED | +| Shell escape (TM-ESC-005) | `exec /bin/bash` | Re-enters the in-process interpreter; host binaries are unreachable (exit 127) | MITIGATED | | External commands (TM-ESC-006) | `./malicious` | Runs in VFS sandbox, no host shell | MITIGATED | | Background proc (TM-ESC-007) | `malicious &` | Background not implemented | MITIGATED | | eval injection (TM-ESC-008) | `eval "$input"` | Sandboxed eval (builtins only) | MITIGATED | diff --git a/crates/bashkit/src/builtins/checksum.rs b/crates/bashkit/src/builtins/checksum.rs index 982629374..9cfe15755 100644 --- a/crates/bashkit/src/builtins/checksum.rs +++ b/crates/bashkit/src/builtins/checksum.rs @@ -79,11 +79,20 @@ async fn read_operand(ctx: &Context<'_>, file: &str) -> std::result::Result(ctx: &Context<'_>, cmd: &str) -> Result { + checksum_execute_args::(ctx, cmd, ctx.args).await +} + +/// [`checksum_execute`] over explicit arguments (`shasum` strips `-a N`). +pub(super) async fn checksum_execute_args( + ctx: &Context<'_>, + cmd: &str, + args: &[String], +) -> Result { let mut files: Vec<&str> = Vec::new(); let mut end_of_options = false; let mut opts = CheckOpts::default(); - for arg in ctx.args { + for arg in args { if end_of_options || arg == "-" || !arg.starts_with('-') { files.push(arg); continue; diff --git a/crates/bashkit/src/builtins/coreutils_extra.rs b/crates/bashkit/src/builtins/coreutils_extra.rs new file mode 100644 index 000000000..3ce771f3b --- /dev/null +++ b/crates/bashkit/src/builtins/coreutils_extra.rs @@ -0,0 +1,344 @@ +//! Small coreutils agents expect: egrep, fgrep, link, unlink, chgrp, +//! shasum, sum. +//! +//! Decision: thin front ends over existing builtins (`grep`, `chown`, the +//! checksum driver) so behavior stays in one place. `link` makes a symbolic +//! link, like `ln` without `-s` (L-FS-001: the VFS has no inodes to share). + +use async_trait::async_trait; +use sha1::Sha1; +use sha2::{Sha224, Sha256, Sha384, Sha512}; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// Re-run a builtin with different arguments. +async fn run_with_args( + builtin: &dyn Builtin, + ctx: Context<'_>, + args: &[String], +) -> Result { + let new_ctx = Context { + args, + env: ctx.env, + variables: ctx.variables, + cwd: ctx.cwd, + fs: ctx.fs, + stdin: ctx.stdin, + #[cfg(feature = "http_client")] + http_client: ctx.http_client, + #[cfg(feature = "git")] + git_client: ctx.git_client, + #[cfg(feature = "ssh")] + ssh_client: ctx.ssh_client, + shell: ctx.shell, + }; + builtin.execute(new_ctx).await +} + +/// `egrep` / `fgrep`: `grep -E` / `grep -F`. GNU grep 3.8+ also warns that +/// they are obsolescent; that warning is left out. +pub struct GrepAlias { + flag: &'static str, +} + +impl GrepAlias { + /// `egrep` = `grep -E` + pub fn egrep() -> Self { + Self { flag: "-E" } + } + /// `fgrep` = `grep -F` + pub fn fgrep() -> Self { + Self { flag: "-F" } + } +} + +#[async_trait] +impl Builtin for GrepAlias { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut args = vec![self.flag.to_string()]; + args.extend(ctx.args.iter().cloned()); + run_with_args(&super::Grep, ctx, &args).await + } +} + +/// `chgrp GROUP FILE...`: ownership is not modeled, like `chown`. +pub struct Chgrp; + +#[async_trait] +impl Builtin for Chgrp { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = super::check_help_version( + ctx.args, + "Usage: chgrp [OPTION]... GROUP FILE...\nChange the group of each FILE to GROUP.\n\n -R, --recursive\toperate on files and directories recursively\n", + Some("chgrp (bashkit) 0.1"), + ) { + return Ok(r); + } + let args = ctx.args.to_vec(); + let mut r = run_with_args(&super::Chown, ctx, &args).await?; + if !r.stderr.is_empty() { + r.stderr = r.stderr.text_lossy().replace("chown", "chgrp").into(); + } + Ok(r) + } +} + +/// `link FILE1 FILE2` and `unlink FILE`. +pub struct Link { + unlink: bool, +} + +impl Link { + /// `link FILE1 FILE2` + pub fn hard() -> Self { + Self { unlink: false } + } + /// `unlink FILE` + pub fn unlink() -> Self { + Self { unlink: true } + } +} + +#[async_trait] +impl Builtin for Link { + async fn execute(&self, ctx: Context<'_>) -> Result { + let (name, usage, want) = if self.unlink { + ( + "unlink", + "Usage: unlink FILE\nCall the unlink function to remove the specified FILE.\n", + 1, + ) + } else { + ( + "link", + "Usage: link FILE1 FILE2\nCall the link function to create a link named FILE2 to an existing FILE1.\n", + 2, + ) + }; + if let Some(r) = + super::check_help_version(ctx.args, usage, Some(&format!("{name} (bashkit) 0.1"))) + { + return Ok(r); + } + let operands: Vec<&String> = match ctx.args.first().map(String::as_str) { + Some("--") => ctx.args[1..].iter().collect(), + _ => ctx.args.iter().collect(), + }; + if let Some(opt) = operands.iter().find(|a| a.starts_with('-') && a.len() > 1) + && ctx.args.first().map(String::as_str) != Some("--") + { + return Ok(super::invalid_option(name, opt, 1)); + } + if operands.len() < want { + let msg = match operands.last() { + Some(a) => format!("{name}: missing operand after '{a}'\n"), + None => format!("{name}: missing operand\n"), + }; + return Ok(ExecResult::err( + format!("{msg}Try '{name} --help' for more information.\n"), + 1, + )); + } + if operands.len() > want { + return Ok(ExecResult::err( + format!( + "{name}: extra operand '{}'\nTry '{name} --help' for more information.\n", + operands[want] + ), + 1, + )); + } + if self.unlink { + let file = operands[0]; + let path = super::resolve_path(ctx.cwd, file); + let meta = match ctx.fs.lstat(&path).await { + Ok(m) => m, + Err(_) => { + return Ok(ExecResult::err( + format!("unlink: cannot unlink '{file}': No such file or directory\n"), + 1, + )); + } + }; + if meta.file_type.is_dir() { + return Ok(ExecResult::err( + format!("unlink: cannot unlink '{file}': Is a directory\n"), + 1, + )); + } + if let Err(e) = ctx.fs.remove(&path, false).await { + return Ok(ExecResult::err( + format!("unlink: cannot unlink '{file}': {e}\n"), + 1, + )); + } + return Ok(ExecResult::ok(String::new())); + } + let (from, to) = (operands[0], operands[1]); + let from_path = super::resolve_path(ctx.cwd, from); + let to_path = super::resolve_path(ctx.cwd, to); + if !ctx.fs.exists(&from_path).await.unwrap_or(false) { + return Ok(ExecResult::err( + format!("link: cannot create link '{to}' to '{from}': No such file or directory\n"), + 1, + )); + } + if ctx.fs.lstat(&to_path).await.is_ok() { + return Ok(ExecResult::err( + format!("link: cannot create link '{to}' to '{from}': File exists\n"), + 1, + )); + } + if let Err(e) = ctx.fs.symlink(&from_path, &to_path).await { + return Ok(ExecResult::err( + format!("link: cannot create link '{to}' to '{from}': {e}\n"), + 1, + )); + } + Ok(ExecResult::ok(String::new())) + } +} + +/// `shasum [-a ALG] ...`: the Perl front end over the SHA family. +pub struct Shasum; + +#[async_trait] +impl Builtin for Shasum { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = super::check_help_version( + ctx.args, + "Usage: shasum [OPTION]... [FILE]...\nPrint or check SHA checksums.\n\n -a, --algorithm\t1 (default), 224, 256, 384, 512\n -c, --check\tread SHA sums from the FILEs and check them\n -b, -t\taccepted for compatibility (no effect)\n", + Some("shasum (bashkit) 0.1"), + ) { + return Ok(r); + } + let mut alg = "1".to_string(); + let mut rest: Vec = Vec::new(); + let mut it = ctx.args.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "-a" | "--algorithm" => match it.next() { + Some(v) => alg = v.clone(), + None => { + return Ok(ExecResult::err( + "shasum: option requires an argument -- 'a'\n".to_string(), + 1, + )); + } + }, + a if a.starts_with("--algorithm=") => alg = a[12..].to_string(), + a if a.starts_with("-a") && a.len() > 2 => alg = a[2..].to_string(), + "-U" | "--UNIVERSAL" | "-0" | "--01" => {} + _ => rest.push(arg.clone()), + } + } + let cmd = "shasum"; + match alg.as_str() { + "1" => super::checksum::checksum_execute_args::(&ctx, cmd, &rest).await, + "224" => super::checksum::checksum_execute_args::(&ctx, cmd, &rest).await, + "256" => super::checksum::checksum_execute_args::(&ctx, cmd, &rest).await, + "384" => super::checksum::checksum_execute_args::(&ctx, cmd, &rest).await, + "512" => super::checksum::checksum_execute_args::(&ctx, cmd, &rest).await, + other => Ok(ExecResult::err( + format!("shasum: Unrecognized algorithm '{other}'\n"), + 1, + )), + } + } +} + +/// BSD 16-bit rotating checksum (`sum -r`, the GNU default). +fn bsd_sum(data: &[u8]) -> (u32, u64) { + let mut checksum: u32 = 0; + for &b in data { + checksum = (checksum >> 1) + ((checksum & 1) << 15); + checksum = (checksum + u32::from(b)) & 0xffff; + } + (checksum, (data.len() as u64).div_ceil(1024)) +} + +/// System V checksum (`sum -s`). +fn sysv_sum(data: &[u8]) -> (u32, u64) { + let s: u32 = data.iter().fold(0u32, |a, &b| a.wrapping_add(u32::from(b))); + let r = (s & 0xffff) + (s >> 16); + ((r & 0xffff) + (r >> 16), (data.len() as u64).div_ceil(512)) +} + +/// `sum [-r|-s] [FILE]...` +pub struct Sum; + +#[async_trait] +impl Builtin for Sum { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = super::check_help_version( + ctx.args, + "Usage: sum [OPTION]... [FILE]...\nPrint checksum and block counts for each FILE.\n\n -r\tuse BSD sum algorithm (the default), use 1K blocks\n -s, --sysv\tuse System V sum algorithm, use 512 bytes blocks\n", + Some("sum (bashkit) 0.1"), + ) { + return Ok(r); + } + let mut sysv = false; + let mut files: Vec<&str> = Vec::new(); + for arg in ctx.args { + match arg.as_str() { + "-r" => sysv = false, + "-s" | "--sysv" => sysv = true, + "-" => files.push("-"), + a if a.starts_with('-') => return Ok(super::invalid_option("sum", a, 1)), + a => files.push(a), + } + } + let named = !files.is_empty(); + if files.is_empty() { + files.push("-"); + } + let mut out = String::new(); + let mut err = String::new(); + for file in files { + let data = if file == "-" { + ctx.stdin.map(|s| s.as_bytes().to_vec()).unwrap_or_default() + } else { + let path = super::resolve_path(ctx.cwd, file); + match ctx.fs.read_file(&path).await { + Ok(d) => d, + Err(_) => { + err.push_str(&format!("sum: {file}: No such file or directory\n")); + continue; + } + } + }; + let line = if sysv { + let (c, blocks) = sysv_sum(&data); + format!("{c} {blocks}") + } else { + let (c, blocks) = bsd_sum(&data); + format!("{c:05} {blocks:>5}") + }; + out.push_str(&line); + if named && file != "-" { + out.push(' '); + out.push_str(file); + } + out.push('\n'); + } + let mut r = ExecResult::with_code(out, i32::from(!err.is_empty())); + r.stderr = err.into(); + Ok(r) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sum_matches_gnu() { + assert_eq!(bsd_sum(b"hello\n"), (36979, 1)); + assert_eq!(sysv_sum(b"hello\n"), (542, 1)); + let big = vec![b'x'; 3000]; + assert_eq!(bsd_sum(&big), (5357, 3)); + assert_eq!(sysv_sum(&big), (32325, 6)); + } +} diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 2a09a6fcc..80ec44ad5 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -41,6 +41,7 @@ mod cmp; mod column; mod comm; mod compgen; +mod coreutils_extra; mod csv; mod curl; mod cuttr; @@ -111,6 +112,7 @@ mod sortuniq; mod source; mod split; mod strings; +mod sysextra; mod system; mod template; mod test; @@ -233,6 +235,7 @@ pub use shellenv::{Enable, Locale, Ulimit, Umask}; pub use shuf::Shuf; pub use tsort::Tsort; +pub use coreutils_extra::{Chgrp, GrepAlias, Link, Shasum, Sum}; pub use jobctl::{Bg, Disown, Fg, Jobs, Kill, Pgrep, Ps}; #[cfg(feature = "terminal")] pub use nano::Nano; @@ -243,6 +246,7 @@ pub use sortuniq::{Sort, Uniq}; pub use source::Source; pub use split::Split; pub use strings::Strings; +pub use sysextra::{Arch, Free, Getconf, Hostid, RunAs, SyncCmd, Tty, Uptime, UserInfo}; pub use system::{DEFAULT_HOSTNAME, DEFAULT_USERNAME, Hostname, Id, Nproc, Uname, Whoami}; pub(crate) use system::{VIRTUAL_KERNEL_RELEASE, VIRTUAL_KERNEL_VERSION, VIRTUAL_NPROC}; pub use template::Template; diff --git a/crates/bashkit/src/builtins/sysextra.rs b/crates/bashkit/src/builtins/sysextra.rs new file mode 100644 index 000000000..bf70a1d6a --- /dev/null +++ b/crates/bashkit/src/builtins/sysextra.rs @@ -0,0 +1,749 @@ +//! Small system commands agents reach for: arch, groups, tty, logname, +//! uptime, free, getconf, sync, hostid, users, who, nohup, nice, flock. +//! +//! Decision: every value is synthetic and matches the rest of the virtual +//! identity (`uname -m`, `id`, `/proc/meminfo`, `nproc`), never the host +//! (TM-INF-008). Commands that only change how a program runs on a real +//! kernel (`nohup`, `nice`, `flock`) run their command through an +//! [`ExecutionPlan`]; there is no scheduling priority or second process to +//! lock against in one interpreter. + +use async_trait::async_trait; + +use super::{Builtin, Context, ExecutionPlan, SubCommand}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// Machine name reported by `arch` and `uname -m`. +pub const VIRTUAL_ARCH: &str = "x86_64"; + +/// Memory size reported by `free` and `/proc/meminfo`, in KiB. +pub const VIRTUAL_MEM_TOTAL_KB: u64 = 4_194_304; +/// Free memory reported by `free` and `/proc/meminfo`, in KiB. +pub const VIRTUAL_MEM_FREE_KB: u64 = 3_145_728; + +fn help(ctx: &Context<'_>, name: &str, usage: &str) -> Option { + super::check_help_version(ctx.args, usage, Some(&format!("{name} (bashkit) 0.1"))) +} + +/// Reject any option or operand for commands that take none. +fn no_args(ctx: &Context<'_>, name: &str) -> Option { + let arg = ctx.args.first()?; + if arg.starts_with('-') && arg.len() > 1 { + Some(super::invalid_option(name, arg, 1)) + } else { + Some(ExecResult::err( + format!("{name}: extra operand '{arg}'\n"), + 1, + )) + } +} + +/// `arch` - print the machine hardware name. +pub struct Arch; + +#[async_trait] +impl Builtin for Arch { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = help(&ctx, "arch", "Usage: arch\nPrint machine architecture.\n") { + return Ok(r); + } + if let Some(r) = no_args(&ctx, "arch") { + return Ok(r); + } + Ok(ExecResult::ok(format!("{VIRTUAL_ARCH}\n"))) + } +} + +/// `groups` and `logname`: answers from the virtual user. +pub struct UserInfo { + kind: UserInfoKind, + username: String, +} + +#[derive(Clone, Copy)] +enum UserInfoKind { + Groups, + Logname, + Users, + Who, +} + +impl UserInfo { + /// `groups [USER]`: the user's only group shares its name. + pub fn groups(username: &str) -> Self { + Self { + kind: UserInfoKind::Groups, + username: username.to_string(), + } + } + + /// `logname`: the login name. + pub fn logname(username: &str) -> Self { + Self { + kind: UserInfoKind::Logname, + username: username.to_string(), + } + } + + /// `users`: logged-in users. Nobody is logged in to a sandbox, like a + /// container without utmp, so the list is empty. + pub fn users() -> Self { + Self { + kind: UserInfoKind::Users, + username: String::new(), + } + } + + /// `who`: login sessions; none in a sandbox. + pub fn who() -> Self { + Self { + kind: UserInfoKind::Who, + username: String::new(), + } + } +} + +#[async_trait] +impl Builtin for UserInfo { + async fn execute(&self, ctx: Context<'_>) -> Result { + match self.kind { + UserInfoKind::Groups => { + if let Some(r) = help( + &ctx, + "groups", + "Usage: groups [USER]...\nPrint group memberships for each USERNAME or the current user.\n", + ) { + return Ok(r); + } + if ctx.args.is_empty() { + return Ok(ExecResult::ok(format!("{}\n", self.username))); + } + let mut out = String::new(); + let mut err = String::new(); + for user in ctx.args { + if user == &self.username { + out.push_str(&format!("{user} : {user}\n")); + } else { + err.push_str(&format!("groups: '{user}': no such user\n")); + } + } + let mut r = ExecResult::with_code(out, i32::from(!err.is_empty())); + r.stderr = err.into(); + Ok(r) + } + UserInfoKind::Logname => { + if let Some(r) = help( + &ctx, + "logname", + "Usage: logname\nPrint the user's login name.\n", + ) { + return Ok(r); + } + if let Some(r) = no_args(&ctx, "logname") { + return Ok(r); + } + Ok(ExecResult::ok(format!("{}\n", self.username))) + } + UserInfoKind::Users => { + if let Some(r) = help( + &ctx, + "users", + "Usage: users [FILE]\nOutput who is currently logged in.\n", + ) { + return Ok(r); + } + Ok(ExecResult::ok(String::new())) + } + UserInfoKind::Who => { + if let Some(r) = help( + &ctx, + "who", + "Usage: who [OPTION]... [ FILE | ARG1 ARG2 ]\nPrint information about users who are currently logged in.\n", + ) { + return Ok(r); + } + Ok(ExecResult::ok(String::new())) + } + } + } +} + +/// `tty` - stdin is a terminal only when the embedder says so (`tty(0, true)`). +pub struct Tty; + +#[async_trait] +impl Builtin for Tty { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = help( + &ctx, + "tty", + "Usage: tty [OPTION]...\nPrint the file name of the terminal connected to standard input.\n\n -s, --silent, --quiet\tprint nothing, only return an exit status\n", + ) { + return Ok(r); + } + let mut silent = false; + for arg in ctx.args { + match arg.as_str() { + "-s" | "--silent" | "--quiet" => silent = true, + a if a.starts_with('-') => return Ok(super::invalid_option("tty", a, 2)), + a => return Ok(ExecResult::err(format!("tty: extra operand '{a}'\n"), 2)), + } + } + // Same switch as `[ -t 0 ]` (`BashBuilder::tty(0, true)`). + let stdin_is_tty = ctx + .variables + .get("_TTY_0") + .or_else(|| ctx.env.get("_TTY_0")) + .is_some_and(|v| v == "1"); + if stdin_is_tty { + return Ok(ExecResult::ok(if silent { "" } else { "/dev/pts/0\n" })); + } + Ok(ExecResult::with_code( + if silent { "" } else { "not a tty\n" }, + 1, + )) + } +} + +/// `uptime` - the virtual machine just booted. +pub struct Uptime { + clock: super::Date, +} + +impl Uptime { + /// Report wall time from the shell's virtual clock. + pub fn with_clock(clock: super::Date) -> Self { + Self { clock } + } +} + +#[async_trait] +impl Builtin for Uptime { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = help( + &ctx, + "uptime", + "Usage: uptime [options]\nTell how long the system has been running.\n\n -p, --pretty\tshow uptime in pretty format\n -s, --since\tsystem up since\n", + ) { + return Ok(r); + } + let (secs, _) = self.clock.now_epoch(); + let now = chrono::DateTime::from_timestamp(secs, 0).unwrap_or_default(); + match ctx.args.first().map(String::as_str) { + None => Ok(ExecResult::ok(format!( + " {} up 0 min, 0 users, load average: 0.00, 0.00, 0.00\n", + now.format("%H:%M:%S") + ))), + Some("-p" | "--pretty") => Ok(ExecResult::ok("up 0 minutes\n".to_string())), + Some("-s" | "--since") => Ok(ExecResult::ok(format!( + "{}\n", + now.format("%Y-%m-%d %H:%M:%S") + ))), + Some(a) => Ok(super::invalid_option("uptime", a, 1)), + } + } +} + +/// `free` - memory from the same virtual numbers as `/proc/meminfo`. +pub struct Free; + +#[async_trait] +impl Builtin for Free { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = help( + &ctx, + "free", + "Usage: free [options]\nDisplay amount of free and used memory in the system.\n\n -b, -k, -m, -g\tshow output in bytes, KiB, MiB, GiB\n -h, --human\tshow human-readable output\n -t, --total\tshow total for RAM + swap\n", + ) { + return Ok(r); + } + // Divisor from KiB, or None for human-readable. + let mut unit: Option<(u64, bool)> = Some((1, false)); + let mut total = false; + for arg in ctx.args { + match arg.as_str() { + "-b" | "--bytes" => unit = Some((1, true)), + "-k" | "--kibi" => unit = Some((1, false)), + "-m" | "--mebi" => unit = Some((1024, false)), + "-g" | "--gibi" => unit = Some((1024 * 1024, false)), + "-h" | "--human" => unit = None, + "-t" | "--total" => total = true, + "-w" | "--wide" | "-l" | "--lohi" => {} + a => return Ok(super::invalid_option("free", a, 1)), + } + } + let fmt = |kb: u64| -> String { + match unit { + Some((_, true)) => (kb * 1024).to_string(), + Some((div, false)) => (kb / div).to_string(), + None => human_kb(kb), + } + }; + let used = VIRTUAL_MEM_TOTAL_KB - VIRTUAL_MEM_FREE_KB; + let mut out = format!( + "{:>15} {:>11} {:>11} {:>11} {:>11} {:>11}\n", + "total", "used", "free", "shared", "buff/cache", "available" + ); + out.push_str(&format!( + "Mem: {:>11} {:>11} {:>11} {:>11} {:>11} {:>11}\n", + fmt(VIRTUAL_MEM_TOTAL_KB), + fmt(used), + fmt(VIRTUAL_MEM_FREE_KB), + fmt(0), + fmt(0), + fmt(VIRTUAL_MEM_FREE_KB) + )); + out.push_str(&format!( + "Swap: {:>11} {:>11} {:>11}\n", + fmt(0), + fmt(0), + fmt(0) + )); + if total { + out.push_str(&format!( + "Total:{:>11} {:>11} {:>11}\n", + fmt(VIRTUAL_MEM_TOTAL_KB), + fmt(used), + fmt(VIRTUAL_MEM_FREE_KB) + )); + } + Ok(ExecResult::ok(out)) + } +} + +/// procps-style human size of a KiB count (`4.0Gi`, `1.0Gi`, `0B`). +fn human_kb(kb: u64) -> String { + if kb == 0 { + return "0B".to_string(); + } + let units = ["Ki", "Mi", "Gi", "Ti"]; + let mut value = kb as f64; + let mut i = 0; + while value >= 1024.0 && i < units.len() - 1 { + value /= 1024.0; + i += 1; + } + if value < 10.0 { + format!("{value:.1}{}", units[i]) + } else { + format!("{}{}", value.round() as u64, units[i]) + } +} + +/// `getconf NAME` - system configuration values. +pub struct Getconf; + +/// Values `getconf` knows, consistent with `nproc` and `/proc/meminfo`. +fn getconf_value(name: &str) -> Option { + let page = 4096u64; + Some(match name { + "_NPROCESSORS_ONLN" | "_NPROCESSORS_CONF" | "NPROCESSORS_ONLN" | "NPROCESSORS_CONF" => { + super::VIRTUAL_NPROC.to_string() + } + "PAGESIZE" | "PAGE_SIZE" | "_SC_PAGESIZE" => page.to_string(), + "_PHYS_PAGES" | "PHYS_PAGES" => (VIRTUAL_MEM_TOTAL_KB * 1024 / page).to_string(), + "_AVPHYS_PAGES" | "AVPHYS_PAGES" => (VIRTUAL_MEM_FREE_KB * 1024 / page).to_string(), + "ARG_MAX" => "2097152".to_string(), + "CHILD_MAX" => "63704".to_string(), + "CLK_TCK" => "100".to_string(), + "OPEN_MAX" => "1024".to_string(), + "LONG_BIT" => "64".to_string(), + "WORD_BIT" => "32".to_string(), + "CHAR_BIT" => "8".to_string(), + "INT_MAX" => i32::MAX.to_string(), + "UINT_MAX" => u32::MAX.to_string(), + "LINE_MAX" => "2048".to_string(), + "HOST_NAME_MAX" => "64".to_string(), + "LOGIN_NAME_MAX" => "256".to_string(), + "PATH_MAX" => "4096".to_string(), + "NAME_MAX" => "255".to_string(), + "PIPE_BUF" => "4096".to_string(), + "GNU_LIBC_VERSION" => "glibc 2.36".to_string(), + "PATH" | "CS_PATH" => "/bin:/usr/bin".to_string(), + _ => return None, + }) +} + +#[async_trait] +impl Builtin for Getconf { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = help( + &ctx, + "getconf", + "Usage: getconf [-a] VARIABLE [PATH]\nQuery system configuration variables.\n", + ) { + return Ok(r); + } + match ctx.args.first().map(String::as_str) { + None => Ok(ExecResult::err( + "Usage: getconf [-v specification] variable_name [pathname]\n getconf -a [pathname]\n" + .to_string(), + 1, + )), + Some("-a") => { + let names = [ + "ARG_MAX", + "CHILD_MAX", + "CLK_TCK", + "HOST_NAME_MAX", + "LINE_MAX", + "LOGIN_NAME_MAX", + "LONG_BIT", + "NAME_MAX", + "OPEN_MAX", + "PAGESIZE", + "PATH_MAX", + "PIPE_BUF", + "_AVPHYS_PAGES", + "_NPROCESSORS_CONF", + "_NPROCESSORS_ONLN", + "_PHYS_PAGES", + ]; + let mut out = String::new(); + for n in names { + if let Some(v) = getconf_value(n) { + out.push_str(&format!("{n:<32}{v}\n")); + } + } + Ok(ExecResult::ok(out)) + } + Some(name) => match getconf_value(name) { + Some(v) => Ok(ExecResult::ok(format!("{v}\n"))), + None => Ok(ExecResult::err( + format!("getconf: Unrecognized variable `{name}'\n"), + 2, + )), + }, + } + } +} + +/// `sync` - the VFS has no write-back cache; succeeds. +pub struct SyncCmd; + +#[async_trait] +impl Builtin for SyncCmd { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = help( + &ctx, + "sync", + "Usage: sync [OPTION] [FILE]...\nSynchronize cached writes to persistent storage.\n", + ) { + return Ok(r); + } + for arg in ctx.args { + match arg.as_str() { + "-d" | "--data" | "-f" | "--file-system" => {} + a if a.starts_with('-') => return Ok(super::invalid_option("sync", a, 1)), + file => { + let path = super::resolve_path(ctx.cwd, file); + if !ctx.fs.exists(&path).await.unwrap_or(false) { + return Ok(ExecResult::err( + format!("sync: error opening '{file}': No such file or directory\n"), + 1, + )); + } + } + } + } + Ok(ExecResult::ok(String::new())) + } +} + +/// `hostid` - fixed identifier (Linux derives it from 127.0.1.1 when +/// `/etc/hostid` is missing). +pub struct Hostid; + +#[async_trait] +impl Builtin for Hostid { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = help( + &ctx, + "hostid", + "Usage: hostid\nPrint the numeric identifier for the current host.\n", + ) { + return Ok(r); + } + if let Some(r) = no_args(&ctx, "hostid") { + return Ok(r); + } + Ok(ExecResult::ok("007f0101\n".to_string())) + } +} + +/// `nohup`, `nice` and `flock`: run a command unchanged. +pub struct RunAs { + kind: RunAsKind, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum RunAsKind { + Nohup, + Nice, + Flock, +} + +impl RunAs { + /// `nohup COMMAND [ARG]...` + pub fn nohup() -> Self { + Self { + kind: RunAsKind::Nohup, + } + } + /// `nice [-n N] [COMMAND [ARG]...]` + pub fn nice() -> Self { + Self { + kind: RunAsKind::Nice, + } + } + /// `flock [OPTIONS] FILE|FD [COMMAND [ARG]... | -c COMMAND]` + pub fn flock() -> Self { + Self { + kind: RunAsKind::Flock, + } + } + + fn name(&self) -> &'static str { + match self.kind { + RunAsKind::Nohup => "nohup", + RunAsKind::Nice => "nice", + RunAsKind::Flock => "flock", + } + } +} + +/// What a runner builtin resolved its arguments to. +enum Run { + /// Run this command. + Command(String, Vec), + /// Finish with this result (usage error, `nice` alone, `flock FD`). + Done(ExecResult), + /// `flock FILE ...`: create the lock file, then continue with the inner + /// resolution. + Lock(String, Box), +} + +fn parse_runner(kind: RunAsKind, args: &[String]) -> Run { + match kind { + RunAsKind::Nohup => { + let args = match args.first().map(String::as_str) { + Some("--") => &args[1..], + _ => args, + }; + match args.split_first() { + None => Run::Done(ExecResult::err( + "nohup: missing operand\nTry 'nohup --help' for more information.\n" + .to_string(), + 125, + )), + Some((cmd, rest)) => Run::Command(cmd.clone(), rest.to_vec()), + } + } + RunAsKind::Nice => { + let mut i = 0; + let mut adjustment = 10i64; + while i < args.len() { + let a = args[i].as_str(); + let value = if a == "-n" || a == "--adjustment" { + i += 1; + args.get(i).map(String::as_str) + } else if let Some(v) = a.strip_prefix("--adjustment=") { + Some(v) + } else if let Some(v) = a.strip_prefix("-n") { + Some(v) + } else if a.len() > 1 + && a.starts_with('-') + && a[1..] + .trim_start_matches('-') + .bytes() + .all(|b| b.is_ascii_digit()) + { + Some(&a[1..]) + } else if a == "--" { + i += 1; + break; + } else { + break; + }; + match value.and_then(|v| v.parse::().ok()) { + Some(n) => adjustment = n, + None => { + return Run::Done(ExecResult::err( + format!("nice: invalid adjustment '{}'\n", value.unwrap_or_default()), + 125, + )); + } + } + i += 1; + } + let _ = adjustment; // no scheduler priority inside one interpreter + match args[i..].split_first() { + // `nice` alone prints the current niceness. + None => Run::Done(ExecResult::ok("0\n".to_string())), + Some((cmd, rest)) => Run::Command(cmd.clone(), rest.to_vec()), + } + } + RunAsKind::Flock => { + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-s" | "--shared" | "-x" | "-e" | "--exclusive" | "-u" | "--unlock" | "-n" + | "--nb" | "--nonblock" | "-o" | "--close" | "-F" | "--no-fork" + | "--verbose" => i += 1, + "-w" | "--wait" | "--timeout" | "-E" | "--conflict-exit-code" => i += 2, + a if a.starts_with("--wait=") + || a.starts_with("--timeout=") + || a.starts_with("--conflict-exit-code=") => + { + i += 1 + } + a if a.starts_with('-') && a.len() > 1 && a != "-c" => { + return Run::Done(super::invalid_option("flock", a, 64)); + } + _ => break, + } + } + let Some(target) = args.get(i) else { + return Run::Done(ExecResult::err( + "flock: not enough arguments\nTry 'flock --help' for more information.\n" + .to_string(), + 64, + )); + }; + let rest = &args[i + 1..]; + let inner = match rest.split_first() { + // `flock FD`: lock an already open descriptor; nothing else + // can hold it here. + None if target.bytes().all(|b| b.is_ascii_digit()) => { + return Run::Done(ExecResult::ok(String::new())); + } + None => { + return Run::Done(ExecResult::err( + format!("flock: {target}: not a number\n"), + 64, + )); + } + Some((c, cmd)) if c == "-c" || c == "--command" => match cmd.first() { + Some(script) => { + Run::Command("bash".to_string(), vec!["-c".to_string(), script.clone()]) + } + None => { + return Run::Done(ExecResult::err( + "flock: option requires an argument -- 'c'\n".to_string(), + 64, + )); + } + }, + Some((cmd, cmd_args)) => Run::Command(cmd.clone(), cmd_args.to_vec()), + }; + Run::Lock(target.clone(), Box::new(inner)) + } + } +} + +async fn create_lock_file(ctx: &Context<'_>, file: &str) -> Result<()> { + let path = super::resolve_path(ctx.cwd, file); + if ctx.fs.exists(&path).await.unwrap_or(false) { + return Ok(()); + } + ctx.fs.write_file(&path, b"").await +} + +#[async_trait] +impl Builtin for RunAs { + async fn execute(&self, ctx: Context<'_>) -> Result { + let usage = match self.kind { + RunAsKind::Nohup => { + "Usage: nohup COMMAND [ARG]...\nRun COMMAND, ignoring hangup signals.\n" + } + RunAsKind::Nice => { + "Usage: nice [OPTION] [COMMAND [ARG]...]\nRun COMMAND with an adjusted niceness.\n\n -n, --adjustment=N\tadd integer N to the niceness (default 10)\n" + } + RunAsKind::Flock => { + "Usage: flock [options] | [...]\n flock [options] | -c \n flock [options] \nManage file locks from shell scripts.\n" + } + }; + if let Some(r) = help(&ctx, self.name(), usage) { + return Ok(r); + } + // Reached only when there is nothing to run (see execution_plan), + // or the lock file could not be created. + match parse_runner(self.kind, ctx.args) { + Run::Done(r) => Ok(r), + Run::Lock(file, _) => match create_lock_file(&ctx, &file).await { + Err(e) => Ok(ExecResult::err( + format!("flock: cannot open lock file {file}: {e}\n"), + 1, + )), + Ok(()) => Ok(ExecResult::ok(String::new())), + }, + Run::Command(..) => Ok(ExecResult::ok(String::new())), + } + } + + async fn execution_plan(&self, ctx: &Context<'_>) -> Result> { + if ctx.args.iter().any(|a| a == "--help" || a == "--version") { + return Ok(None); + } + let mut run = parse_runner(self.kind, ctx.args); + if let Run::Lock(file, inner) = run { + // flock creates the lock file if needed, then runs the command; + // on failure `execute` reports the error. + if create_lock_file(ctx, &file).await.is_err() { + return Ok(None); + } + run = *inner; + } + match run { + Run::Command(name, args) => Ok(Some(ExecutionPlan::Batch { + commands: vec![SubCommand { + name, + args, + stdin: ctx.stdin.cloned(), + assignments: Vec::new(), + }], + })), + _ => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_sizes_match_procps() { + assert_eq!(human_kb(VIRTUAL_MEM_TOTAL_KB), "4.0Gi"); + assert_eq!(human_kb(VIRTUAL_MEM_FREE_KB), "3.0Gi"); + assert_eq!(human_kb(0), "0B"); + assert_eq!(human_kb(512), "512Ki"); + } + + #[test] + fn getconf_knows_common_names() { + assert_eq!(getconf_value("_NPROCESSORS_ONLN").as_deref(), Some("4")); + assert_eq!(getconf_value("PAGESIZE").as_deref(), Some("4096")); + assert_eq!(getconf_value("LONG_BIT").as_deref(), Some("64")); + assert_eq!(getconf_value("WORD_BIT").as_deref(), Some("32")); + assert!(getconf_value("NOPE").is_none()); + } + + #[test] + fn nice_parses_adjustments() { + let args = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + assert!(matches!( + parse_runner(RunAsKind::Nice, &args(&["-n", "5", "echo", "x"])), + Run::Command(c, _) if c == "echo" + )); + assert!(matches!( + parse_runner(RunAsKind::Nice, &args(&["-5", "echo"])), + Run::Command(c, _) if c == "echo" + )); + assert!(matches!( + parse_runner(RunAsKind::Nice, &args(&["-n", "x", "echo"])), + Run::Done(r) if r.exit_code == 125 + )); + } +} diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index f84dad2a5..3857b54d7 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -429,6 +429,13 @@ const SPECIAL_BUILTIN_NAMES: &[&str] = &[ "source", "typeset", "unset", ]; +/// Interpreter-dispatched names that real bash reports as shell builtins +/// (`type builtin`, `command -V let`) although they have no entry in the +/// builtin map. `bash`/`sh` are programs in real bash, not builtins. +fn is_dispatch_only_builtin(name: &str) -> bool { + SPECIAL_BUILTIN_NAMES.contains(&name) && !matches!(name, "bash" | "sh") +} + /// Sorted, deduped union of baked-in/custom builtins, interpreter-special /// builtins, and the host registry. fn merged_builtin_names( @@ -453,7 +460,7 @@ impl ShellRef<'_> { /// Check if a name is a registered builtin command. pub(crate) fn has_builtin(&self, name: &str) -> bool { - self.builtins.contains_key(name) + self.builtins.contains_key(name) || is_dispatch_only_builtin(name) } /// Sorted names of all dispatchable builtins (registered + special + host @@ -1685,6 +1692,15 @@ impl Interpreter { "factor" => Factor, "tsort" => Tsort, "nproc" => Nproc, + "arch" => Arch, + "tty" => Tty, + "free" => Free, + "getconf" => Getconf, + "sync" => SyncCmd, + "hostid" => Hostid, + "chgrp" => Chgrp, + "shasum" => Shasum, + "sum" => Sum, "dd" => Dd, "install" => Install, "umask" => Umask, @@ -1791,6 +1807,13 @@ impl Interpreter { Arc::new(builtins::Source::new(fs.clone())), ); builtins.insert(".".to_string(), Arc::new(builtins::Source::new(fs.clone()))); + builtins.insert("nohup".to_string(), Arc::new(builtins::RunAs::nohup())); + builtins.insert("nice".to_string(), Arc::new(builtins::RunAs::nice())); + builtins.insert("flock".to_string(), Arc::new(builtins::RunAs::flock())); + builtins.insert("egrep".to_string(), Arc::new(builtins::GrepAlias::egrep())); + builtins.insert("fgrep".to_string(), Arc::new(builtins::GrepAlias::fgrep())); + builtins.insert("link".to_string(), Arc::new(builtins::Link::hard())); + builtins.insert("unlink".to_string(), Arc::new(builtins::Link::unlink())); // THREAT[TM-INF-018]: Resolve the virtual clock mode for `date`. // Priority: fixed_epoch > epoch_offset > real clock. @@ -1828,6 +1851,20 @@ impl Interpreter { "whoami".to_string(), Arc::new(builtins::Whoami::with_username(&username_val)), ); + builtins.insert( + "groups".to_string(), + Arc::new(builtins::UserInfo::groups(&username_val)), + ); + builtins.insert( + "logname".to_string(), + Arc::new(builtins::UserInfo::logname(&username_val)), + ); + builtins.insert("users".to_string(), Arc::new(builtins::UserInfo::users())); + builtins.insert("who".to_string(), Arc::new(builtins::UserInfo::who())); + builtins.insert( + "uptime".to_string(), + Arc::new(builtins::Uptime::with_clock(clock)), + ); builtins.insert( "find".to_string(), Arc::new(builtins::Find::new(clock, &username_val)), @@ -5437,6 +5474,18 @@ fn route_fd_table_content( &mut new_stderr, ); + // `N>file` creates (or truncates) the file even when nothing writes to + // fd N. + for (_, target) in extra_fd_targets { + route( + &crate::StreamData::new(), + target, + &mut file_writes, + &mut new_stdout, + &mut new_stderr, + ); + } + // Route pending fd3+ output for (fd_num, data) in pending { let target = extra_fd_targets @@ -7706,10 +7755,19 @@ impl Interpreter { /// every registered builtin that also exists as a program on a real /// system (shell-only builtins like `cd` do not). pub(crate) fn rootfs_command_names(&self) -> impl Iterator + Clone { + // `bash`/`sh` are dispatched by the interpreter, not the map, but + // real systems ship them as `/bin/bash` and `/bin/sh`. Without + // script execution they do not run, so no stub either. + let shells: &'static [&'static str] = if self.shell_features.has_script_execution() { + &["bash", "sh"] + } else { + &[] + }; self.builtins .keys() .map(String::as_str) .filter(|n| !ENV_SHELL_ONLY_BUILTINS.contains(n)) + .chain(shells.iter().copied()) } async fn resolve_command_path(&self, name: &str) -> Option { @@ -8443,8 +8501,23 @@ impl Interpreter { let prev_pipeline_stdin = self.pipeline_stdin.take(); self.pipeline_stdin = stdin; + // `f 3>file`: writes to fd 3 inside the body route to the file. + let capture_pending_fd = redirection::has_high_fd_file_redirect(redirects); + if capture_pending_fd { + if self.pending_fd_capture_depth == 0 { + self.clear_pending_fd_redirect_state(); + } + self.pending_fd_capture_depth += 1; + } + // Execute function body. Always restore call state even on error. let result = self.execute_command(&func_def.body).await; + if capture_pending_fd { + self.pending_fd_capture_depth = self.pending_fd_capture_depth.saturating_sub(1); + if result.is_err() { + self.clear_pending_fd_redirect_state(); + } + } // Restore previous pipeline stdin self.pipeline_stdin = prev_pipeline_stdin; @@ -9092,6 +9165,7 @@ impl Interpreter { 'v' => { // command -v: print name/path if it's a known command let registered = self.builtins.contains_key(cmd_name.as_str()) + || is_dispatch_only_builtin(cmd_name) || self.has_host_builtin(cmd_name); let output = if self.scoped.functions.contains_key(cmd_name.as_str()) || is_keyword(cmd_name) @@ -9120,6 +9194,7 @@ impl Interpreter { 'V' => { // command -V: verbose description let registered = self.has_host_builtin(cmd_name) + || is_dispatch_only_builtin(cmd_name) || self.builtins.contains_key(cmd_name.as_str()); let path = if registered && builtins::BASH_BUILTIN_NAMES.contains(&cmd_name.as_str()) { @@ -10047,7 +10122,8 @@ impl Interpreter { | RedirectKind::OutputBoth ) }); - let capture_pending_fd = has_dup_output && has_file_redirect; + let capture_pending_fd = (has_dup_output && has_file_redirect) + || redirection::has_high_fd_file_redirect(redirects); if capture_pending_fd { if self.pending_fd_capture_depth == 0 { self.clear_pending_fd_redirect_state(); diff --git a/crates/bashkit/src/interpreter/redirection.rs b/crates/bashkit/src/interpreter/redirection.rs index f2539ae5e..682203420 100644 --- a/crates/bashkit/src/interpreter/redirection.rs +++ b/crates/bashkit/src/interpreter/redirection.rs @@ -126,7 +126,9 @@ impl Interpreter { ) }); - if has_dup_output && has_file_redirect { + // `N>file` (N>=3) opens fd N without touching stdout; only the + // fd-table path keeps fd N separate from fd 1. + if (has_dup_output && has_file_redirect) || has_high_fd_file_redirect(redirects) { return self.apply_redirections_fd_table(result, redirects).await; } @@ -396,6 +398,7 @@ impl Interpreter { }; match redirect.fd { Some(2) => fd2 = target, + Some(n) if n >= 3 => self.pending_fd_targets.push((n, target)), _ => fd1 = target, } } @@ -414,6 +417,7 @@ impl Interpreter { }; match redirect.fd { Some(2) => fd2 = target, + Some(n) if n >= 3 => self.pending_fd_targets.push((n, target)), _ => fd1 = target, } } @@ -516,3 +520,13 @@ impl Interpreter { } } } + +/// Whether any redirect opens a file on fd 3 or above (`3>f`, `9>>lock`). +pub(super) fn has_high_fd_file_redirect(redirects: &[Redirect]) -> bool { + redirects.iter().any(|r| { + matches!( + r.kind, + RedirectKind::Output | RedirectKind::Clobber | RedirectKind::Append + ) && r.fd.is_some_and(|fd| fd >= 3) + }) +} diff --git a/crates/bashkit/tests/integration/limitations_evidence_tests.rs b/crates/bashkit/tests/integration/limitations_evidence_tests.rs index 6ddd53973..6d0f63f3b 100644 --- a/crates/bashkit/tests/integration/limitations_evidence_tests.rs +++ b/crates/bashkit/tests/integration/limitations_evidence_tests.rs @@ -29,15 +29,21 @@ async fn l_proc_002_no_job_control() { #[tokio::test] async fn l_proc_003_no_process_spawning() { let mut bash = Bash::new(); - // `sh -c 'echo hi'` style host escape: /bin/sh is not a spawnable path. - let result = bash.exec("/bin/sh -c 'echo escaped'").await.unwrap(); + // A host program path is not spawnable. + let result = bash.exec("/usr/bin/gcc -v").await.unwrap(); assert_eq!(result.exit_code, 127); assert!( result.stderr.contains("No such file or directory"), "stderr: {}", result.stderr ); - assert!(!result.stdout.contains("escaped")); + // `/bin/sh` is a root-filesystem stub for the in-process interpreter: it + // sees the VFS, never the host. + let result = bash + .exec("echo vfs > /tmp/f; /bin/sh -c 'cat /tmp/f; [ -e /proc/1/exe ] || echo no-host'") + .await + .unwrap(); + assert_eq!(result.stdout, "vfs\nno-host\n"); let result = bash.exec("definitely-not-a-command").await.unwrap(); assert_eq!(result.exit_code, 127); diff --git a/crates/bashkit/tests/integration/threat_model_tests.rs b/crates/bashkit/tests/integration/threat_model_tests.rs index f284b3b4f..d182568c3 100644 --- a/crates/bashkit/tests/integration/threat_model_tests.rs +++ b/crates/bashkit/tests/integration/threat_model_tests.rs @@ -338,16 +338,23 @@ mod sandbox_escape { /// Test exec cannot escape sandbox — only VFS scripts are reachable /// - /// exec now executes commands within the VFS (run + exit). Since the VFS - /// doesn't contain /bin/bash, exec /bin/bash still fails with exit 127. - /// This preserves the security invariant: no real process replacement. + /// exec executes commands within the VFS (run + exit). A host program + /// path is not there, so it fails with exit 127; `/bin/bash` is a + /// root-filesystem stub that re-enters the in-process interpreter. + /// Either way there is no real process replacement. #[tokio::test] async fn threat_exec_not_available() { let mut bash = Bash::new(); - let result = bash.exec("exec /bin/bash").await.unwrap(); - // exec tries to run /bin/bash in VFS — doesn't exist, so exit 127 + let result = bash.exec("exec /usr/bin/gcc").await.unwrap(); assert_eq!(result.exit_code, 127); + + let result = bash + .exec("exec /bin/bash -c 'cat /etc/passwd'") + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert!(!result.stdout.contains("root:x:0:0"), "host passwd leaked"); } /// Test exec argv is never re-parsed as shell source (quote injection safe). diff --git a/crates/bashkit/tests/integration/tty_tests.rs b/crates/bashkit/tests/integration/tty_tests.rs index 94eebd29a..cd89cb010 100644 --- a/crates/bashkit/tests/integration/tty_tests.rs +++ b/crates/bashkit/tests/integration/tty_tests.rs @@ -63,3 +63,15 @@ async fn tty_false_overrides_env_true() { let result = bash.exec("[ -t 1 ] && echo yes || echo no").await.unwrap(); assert_eq!(result.stdout.trim(), "no"); } + +/// `tty` agrees with `[ -t 0 ]`. +#[tokio::test] +async fn tty_command_follows_builder() { + let mut bash = Bash::new(); + let result = bash.exec("tty; echo rc=$?").await.unwrap(); + assert_eq!(result.stdout, "not a tty\nrc=1\n"); + + let mut bash = Bash::builder().tty(0, true).build(); + let result = bash.exec("tty; tty -s; echo rc=$?").await.unwrap(); + assert_eq!(result.stdout, "/dev/pts/0\nrc=0\n"); +} diff --git a/crates/bashkit/tests/spec_cases/bash/exec-fd-redirect.test.sh b/crates/bashkit/tests/spec_cases/bash/exec-fd-redirect.test.sh index 2922ed765..1ea5c750a 100644 --- a/crates/bashkit/tests/spec_cases/bash/exec-fd-redirect.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/exec-fd-redirect.test.sh @@ -44,3 +44,45 @@ cat /tmp/test_fd.txt progress file content ### end + +### high_fd_file_redirect_keeps_stdout +# `N>file` (N>=3) opens fd N only; stdout still reaches the terminal. +d=$(mktemp -d); cd "$d" +echo pre > g +echo visible 4>g +wc -c < g +echo z 5>>h; [ -f h ] && echo created +### expect +visible +0 +z +created +### end + +### compound_high_fd_file_redirect +# Writes to fd N inside the block land in the file; stdout is untouched. +d=$(mktemp -d); cd "$d" +{ echo to-fd >&3; echo out; } 3>f +cat f +( echo sub ) 9>lk; [ -f lk ] && echo lock-created +for i in 1; do echo loop; echo app >&3; done 3>>f +cat f +### expect +out +to-fd +sub +lock-created +loop +to-fd +app +### end + +### function_high_fd_file_redirect +d=$(mktemp -d); cd "$d" +f() { echo fn-out; echo fn-fd >&3; } +f 3>a +cat a +### expect +fn-out +fn-fd +### end diff --git a/crates/bashkit/tests/spec_cases/bash/system-commands.test.sh b/crates/bashkit/tests/spec_cases/bash/system-commands.test.sh new file mode 100644 index 000000000..6919c28e0 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/system-commands.test.sh @@ -0,0 +1,202 @@ +# Commonly scripted coreutils/util-linux commands: arch, sum, shasum, +# egrep/fgrep, link/unlink, chgrp, nohup/nice/flock, getconf, tty, sync, +# plus virtual-identity commands (groups, logname, users, who, uptime, free, +# hostid). + +### arch_machine +arch +### expect +x86_64 +### end + +### sum_bsd_and_sysv +d=$(mktemp -d); cd "$d" +printf 'hello\n' > h +sum h; sum -r h; sum -s h; printf 'hello\n' | sum +head -c 3000 /dev/zero | tr '\0' x > big +sum big; sum -s big +### expect +36979 1 h +36979 1 h +542 1 h +36979 1 +05357 3 big +32325 6 big +### end + +### shasum_algorithms +printf 'hello\n' | shasum +printf 'hello\n' | shasum -a 256 +printf 'hello\n' | shasum -a 1 | cut -c1-8 +### expect +f572d396fae9206628714fb2ce00f72e94f2258f - +5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 - +f572d396 +### end + +### egrep_fgrep +printf 'a\nb\nfoo\n' | egrep 'a|foo' 2>/dev/null +printf 'a.b\naxb\n' | fgrep 'a.b' 2>/dev/null +printf 'x\n' | fgrep -c y 2>/dev/null; echo rc=$? +### expect +a +foo +a.b +0 +rc=1 +### end + +### link_unlink +d=$(mktemp -d); cd "$d" +printf 'hello\n' > h +link h h2; cat h2 +unlink h2; [ -e h2 ] || echo gone +unlink missing 2>/dev/null; echo rc=$? +### expect +hello +gone +rc=1 +### end + +### chgrp_same_group +d=$(mktemp -d); cd "$d" +touch f +chgrp "$(id -gn)" f; echo rc=$? +### expect +rc=0 +### end + +### nohup_nice_run_command +nohup echo hi 2>/dev/null +nice -n 5 echo nice +nice -n 5 sh -c 'exit 3'; echo rc=$? +nohup sh -c 'exit 4' 2>/dev/null; echo rc=$? +### expect +hi +nice +rc=3 +rc=4 +### end + +### nice_without_command +nice +### expect +0 +### end + +### nohup_missing_command +nohup 2>/dev/null; echo rc=$? +nice -n 2>/dev/null; echo rc=$? +### expect +rc=125 +rc=125 +### end + +### flock_runs_command +d=$(mktemp -d); cd "$d" +flock lk echo locked +flock lk -c 'echo inner; exit 2'; echo rc=$? +[ -f lk ] && echo lockfile +### expect +locked +inner +rc=2 +lockfile +### end + +### flock_fd_form +d=$(mktemp -d); cd "$d" +( flock -n 9 && echo got ) 9>lk +### expect +got +### end + +### getconf_common +getconf PAGE_SIZE +getconf PAGESIZE +getconf LONG_BIT +getconf CHAR_BIT +getconf NOT_A_VAR 2>/dev/null; echo rc=$? +### expect +4096 +4096 +64 +8 +rc=2 +### end + +### tty_not_a_terminal +tty < /dev/null; echo rc=$? +tty -s < /dev/null; echo rc=$? +### expect +not a tty +rc=1 +rc=1 +### end + +### sync_succeeds +sync; echo rc=$? +### expect +rc=0 +### end + +### type_dispatch_builtins +type builtin +type -t let +command -V typeset +### expect +builtin is a shell builtin +builtin +typeset is a shell builtin +### end + +### shells_have_bin_paths +[ -x /bin/sh ] && echo sh +[ -x /bin/bash ] && echo bash +/bin/sh -c 'echo via-sh' +### expect +sh +bash +via-sh +### end + +### groups_logname_virtual_user +### bash_diff: bashkit reports its configured virtual user, never the host's +groups +logname +groups "$(whoami)" +### expect +sandbox +sandbox +sandbox : sandbox +### end + +### users_who_empty +### bash_diff: no login sessions exist in the sandbox +users; who; echo end +### expect +end +### end + +### free_virtual_memory +### bash_diff: bashkit reports fixed virtual memory, never the host's +free | head -1 | tr -s ' ' +free -m | awk 'NR==2 {print $1, $2}' +### expect + total used free shared buff/cache available +Mem: 4096 +### end + +### uptime_shape +### bash_diff: uptime is derived from the virtual clock +uptime -p | grep -q '^up ' && echo ok +### expect +ok +### end + +### hostid_fixed +### bash_diff: bashkit reports a fixed virtual host id +hostid +### expect +007f0101 +### end diff --git a/knowledge/foundations/vfs.md b/knowledge/foundations/vfs.md index 1d96599a1..cec76154b 100644 --- a/knowledge/foundations/vfs.md +++ b/knowledge/foundations/vfs.md @@ -172,7 +172,7 @@ mounts and custom filesystems always win. - `/bin`, `/usr/bin`: one virtual stub per registered builtin that is not shell-only, answered from a shared name set on lookup and never stored as files (materializing them cost ~2 ms per `Bash` build). A stub's content starts with `STUB_MARKER`; executing it by - path (`/usr/bin/env`, `/bin/ls`) dispatches the builtin. `type`/`which`/ + path (`/usr/bin/env`, `/bin/ls`) dispatches the builtin. `bash` and `sh` also get stubs (when script execution is enabled) so `/bin/sh -c ...` and `#!/bin/bash` re-enter the in-process interpreter. `type`/`which`/ `command -v` report `/usr/bin/NAME` for non-bash builtins via a PATH search (`search_path`), and keep `builtin` for the bash 5.2 set (`BASH_BUILTIN_NAMES`). diff --git a/knowledge/log.md b/knowledge/log.md index 1373f7fbc..04f57f14b 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,8 @@ ## 2026-10-07 +* **Feature**: Missing everyday commands: `arch`, `sum`, `shasum`, `egrep`, `fgrep`, `link`, `unlink`, `chgrp`, `nohup`, `nice`, `flock`, `getconf`, `tty`, `sync`, `hostid`, `groups`, `logname`, `users`, `who`, `uptime`, `free`. Output matches GNU coreutils/util-linux where the answer is not host identity (spec `system-commands.test.sh` checked against real bash); identity answers (`free`, `uptime`, `groups`, `hostid`) are virtual and agree with `nproc` and `/proc/meminfo`. `nohup`/`nice`/`flock` just run their command: there are no signals, priorities or competing lock holders inside one session. `/bin/bash` and `/bin/sh` now exist in the root filesystem, and `type`/`command -V` report interpreter-dispatched builtins (`builtin`, `command`, `let`, `typeset`, `exec`, `getopts`, `declare`) instead of "not found". +* **Fix**: `cmd N>file` with N≥3 used to send the command's stdout into the file, so `( flock -n 9 && ... ) 9>lock` printed nothing. Such redirects now open fd N only: stdout is untouched, the file is created/truncated, and writes to `>&N` inside a compound command or function land in it. * **Fix**: A backslash escape right after a quoted segment broke lexing: `'a'\''b'` (the standard way to put a quote inside single quotes, as in `sh -c '...'\''...'\''...'`) failed with "unterminated single quote", and `"a"\$x`, `'a'\ b`, `"g"\*` kept the backslash. The quote-continuation reader now treats `\c` as a quoted `c` and `\` as a continuation. Still open: single quotes inside an unquoted `${x:-'d'}` operand are not removed. * **Fix**: `$SECONDS` counts whole seconds since the `Bash` instance was built (it was always 0), and `SECONDS=N` restarts the count from N; subshells keep their own. The rootfs gained `/proc/sys/kernel/random/uuid`, a fresh v4 UUID per read from the same CSPRNG as `uuidgen`. See [VFS](foundations/vfs.md). * **Feature**: Pipelines stream. From the first stage that runs shell code, stages run concurrently on forked shells over 4 KiB pipes, with backpressure and SIGPIPE: `while :; do echo x; done | head -n 2` stops the loop (`PIPESTATUS` `141 0`) instead of running it into the command limit. Single-builtin leading stages keep running in sequence (no fork). The pipe is 4 KiB rather than Linux's 64 KiB because every byte an endless producer runs ahead costs budgeted commands. Nested streaming is capped at 4 levels after a debug-build stack measurement (TM-DOS-124). L-PIPE-001 narrowed. See [Parallel Execution](foundations/parallel-execution.md#pipelines). diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index b177f2943..f3fb4ca23 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -47,7 +47,7 @@ execution model. Evidence is a threat-model ID, a test, or `stance` | L-PROC-003 | No process spawning; external commands run as builtins | Core sandbox model: no fork/exec escape surface | `l_proc_003_no_process_spawning` | | L-PIPE-001 | Pipeline stages stream only from the first stage that runs shell code (loop, group, function, `eval`). Leading single-builtin stages run to completion first and hand over their whole output, so they never get SIGPIPE: `yes \| head -1` gives `PIPESTATUS` `1 0` plus yes's output-cap notice, where bash gives `141 0`. Downstream of a streaming stage, a single builtin reads all its input before it starts, except `head` and `read`: `while :; do echo; done \| cat \| head -1` runs until a limit. Pipes hold 4 KiB, not 64 KiB, so a finite loop writing more than that into an early-exiting reader exits 141 where bash would exit 0. Pipelines nested more than 4 subshells deep run their stages in sequence | Builtins return their output as one value, not a stream; streaming them needs a reader/writer `Context`. The smaller pipe bounds how far a producer runs ahead (each byte costs budgeted commands); the nesting cap bounds native stack (TM-DOS-124) | `l_pipe_001_stages_run_sequentially` | | L-RAND-001 | `uuidgen` makes random (v4) UUIDs only (no `-t`, `-m`, `-s`); `openssl` implements only `rand` | Time/MAC-based UUIDs would leak host identity; a full TLS/crypto toolkit is out of scope | `uuidgen_time_based_unsupported` | -| L-FS-001 | Symlinks are followed, but `..` after a linked directory resolves lexically (`/link/..` is the link's parent, the `cd -L` view), and `ln` without `-s` makes a symlink, not a hard link | The interpreter normalizes paths before the VFS sees them; the VFS has no inodes to share | `symlink.test.sh`, `ln_default_symbolic` | +| L-FS-001 | Symlinks are followed, but `..` after a linked directory resolves lexically (`/link/..` is the link's parent, the `cd -L` view), and `ln` without `-s` (and `link`) makes a symlink, not a hard link | The interpreter normalizes paths before the VFS sees them; the VFS has no inodes to share | `symlink.test.sh`, `ln_default_symbolic` | | L-ROOTFS-001 | Default rootfs is static and read-only: `/proc` has no `self`, pid dirs, `uptime` or live counters; `/etc/passwd` has no root entry; `/dev/zero` yields 1 MiB per read; `/bin`, `/usr/bin` are stubs that dispatch builtins | Host state must not leak (TM-INF-003, TM-ISO-018); fixed values keep runs deterministic | `rootfs_layout`, `threat_etc_passwd_blocked` | | L-FS-002 | No file permission enforcement in the VFS | Single-tenant virtual FS; permissions would be theater | `l_fs_002_no_permission_enforcement` | | L-FS-003 | On Windows, `RealFs::symlink()` validates the target but creates an empty host file rather than a symlink/reparse point; pre-existing host symlinks and junctions remain readable subject to containment checks | Windows requires choosing file-vs-directory link semantics and may require link privileges; the portable VFS symlink contract does not carry that host metadata | TM-ESC-033 | diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index b964ed051..a87efda01 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -390,7 +390,7 @@ and `mount_points_are_normalized_when_the_table_is_built`. | ID | Threat | Attack Vector | Mitigation | Status | |----|--------|--------------|------------|--------| -| TM-ESC-005 | Shell escape | `exec /bin/bash` | exec runs command within VFS sandbox then exits (no real process replacement); host binaries unreachable | **MITIGATED** | +| TM-ESC-005 | Shell escape | `exec /bin/bash` | exec runs command within VFS sandbox then exits (no real process replacement); host binaries unreachable. `/bin/bash` and `/bin/sh` are root-filesystem stubs that re-enter the in-process interpreter | **MITIGATED** | | TM-ESC-006 | Subprocess | `./malicious` | Script execution runs within VFS sandbox (no host shell) | **MITIGATED** | | TM-ESC-007 | Background proc | `malicious &` | Background not implemented | **MITIGATED** | | TM-ESC-008 | eval injection | `eval "$user_input"` | eval runs in sandbox (builtins only) | **MITIGATED** | diff --git a/knowledge/status/builtins.json b/knowledge/status/builtins.json index 2173cf146..bd689fb6c 100644 --- a/knowledge/status/builtins.json +++ b/knowledge/status/builtins.json @@ -17,6 +17,10 @@ "feature": null, "name": "alias" }, + { + "feature": null, + "name": "arch" + }, { "feature": null, "name": "assert" @@ -93,6 +97,10 @@ "feature": null, "name": "cd" }, + { + "feature": null, + "name": "chgrp" + }, { "feature": null, "name": "chmod" @@ -197,6 +205,10 @@ "feature": null, "name": "echo" }, + { + "feature": null, + "name": "egrep" + }, { "feature": null, "name": "enable" @@ -249,6 +261,10 @@ "feature": null, "name": "fg" }, + { + "feature": null, + "name": "fgrep" + }, { "feature": null, "name": "file" @@ -257,10 +273,22 @@ "feature": null, "name": "find" }, + { + "feature": null, + "name": "flock" + }, { "feature": null, "name": "fold" }, + { + "feature": null, + "name": "free" + }, + { + "feature": null, + "name": "getconf" + }, { "feature": null, "name": "getopts" @@ -277,6 +305,10 @@ "feature": null, "name": "grep" }, + { + "feature": null, + "name": "groups" + }, { "feature": null, "name": "gunzip" @@ -305,6 +337,10 @@ "feature": null, "name": "history" }, + { + "feature": null, + "name": "hostid" + }, { "feature": null, "name": "hostname" @@ -353,6 +389,10 @@ "feature": null, "name": "let" }, + { + "feature": null, + "name": "link" + }, { "feature": null, "name": "ln" @@ -369,6 +409,10 @@ "feature": null, "name": "log" }, + { + "feature": null, + "name": "logname" + }, { "feature": null, "name": "ls" @@ -409,6 +453,10 @@ "feature": null, "name": "nano" }, + { + "feature": null, + "name": "nice" + }, { "feature": null, "name": "nl" @@ -417,6 +465,10 @@ "feature": "typescript", "name": "node" }, + { + "feature": null, + "name": "nohup" + }, { "feature": null, "name": "nproc" @@ -577,6 +629,10 @@ "feature": null, "name": "sha512sum" }, + { + "feature": null, + "name": "shasum" + }, { "feature": null, "name": "shift" @@ -625,6 +681,14 @@ "feature": null, "name": "strings" }, + { + "feature": null, + "name": "sum" + }, + { + "feature": null, + "name": "sync" + }, { "feature": null, "name": "tac" @@ -693,6 +757,10 @@ "feature": null, "name": "tsort" }, + { + "feature": null, + "name": "tty" + }, { "feature": null, "name": "type" @@ -729,6 +797,10 @@ "feature": null, "name": "uniq" }, + { + "feature": null, + "name": "unlink" + }, { "feature": null, "name": "unset" @@ -737,6 +809,14 @@ "feature": null, "name": "unzip" }, + { + "feature": null, + "name": "uptime" + }, + { + "feature": null, + "name": "users" + }, { "feature": null, "name": "uuidgen" @@ -769,6 +849,10 @@ "feature": null, "name": "which" }, + { + "feature": null, + "name": "who" + }, { "feature": null, "name": "whoami" @@ -794,5 +878,5 @@ "name": "zip" } ], - "count": 198 + "count": 219 }