diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 31e797c..c71515c 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -4455,6 +4455,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "sausage-fs-tools" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "sausage-spawn", +] + [[package]] name = "sausage-grep" version = "0.1.0" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index 437bcba..c569339 100644 --- a/userland/Cargo.toml +++ b/userland/Cargo.toml @@ -1,7 +1,7 @@ # The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's. [workspace] resolver = "3" -members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools"] +members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools"] # WASI compatibility patches: git/vendor/PATCHES.md. [patch.crates-io] diff --git a/userland/archive-tools/README.md b/userland/archive-tools/README.md index 7885a1a..b7b6d2c 100644 --- a/userland/archive-tools/README.md +++ b/userland/archive-tools/README.md @@ -17,6 +17,7 @@ Extraction supports regular files and directories. It rejects special files and Creation uses portable modes because WASI does not expose Unix mode bits. It includes symbolic links in archives, but world extraction does not create symbolic links or hardlinks. The selected `-C` applies to all input paths. +With `-v`, creation prints names to stdout for named archives and to stderr for `-f -`. Gzip supports `-d`, `-c`, `-k`, and `-f`. Gunzip defaults to decompression. Zcat defaults to decompression to stdout. diff --git a/userland/archive-tools/src/tar.rs b/userland/archive-tools/src/tar.rs index 3a60bb0..a0ebde8 100644 --- a/userland/archive-tools/src/tar.rs +++ b/userland/archive-tools/src/tar.rs @@ -10,7 +10,7 @@ fn append( builder: &mut tar::Builder, source: &Path, name: &Path, - verbose: bool, + verbose: Option, ) -> io::Result<()> { let meta = std::fs::symlink_metadata(source)?; let mut header = tar::Header::new_gnu(); @@ -22,8 +22,13 @@ fn append( .unwrap_or_default() .as_secs(), ); - if verbose { - eprintln!("{}", name.display()); + if let Some(stdout) = verbose { + let suffix = if meta.is_dir() { "/" } else { "" }; + if stdout { + println!("{}{suffix}", name.display()); + } else { + eprintln!("{}{suffix}", name.display()); + } } if meta.is_dir() { header.set_entry_type(tar::EntryType::Directory); @@ -57,7 +62,12 @@ fn append( } Ok(()) } -fn create(writer: W, cwd: &Path, paths: &[String], verbose: bool) -> io::Result { +fn create( + writer: W, + cwd: &Path, + paths: &[String], + verbose: Option, +) -> io::Result { let mut builder = tar::Builder::new(writer); for path in paths { let name = Path::new(path).strip_prefix("/").unwrap_or(Path::new(path)); @@ -127,6 +137,7 @@ fn run() -> Result<(), Box> { if paths.is_empty() { return Err("refusing to create an empty archive".into()); } + let verbose = verbose.then_some(archive != "-"); let writer: Box = if archive == "-" { Box::new(io::stdout()) } else { diff --git a/userland/archive-tools/test.nu b/userland/archive-tools/test.nu index 161675a..18f18eb 100644 --- a/userland/archive-tools/test.nu +++ b/userland/archive-tools/test.nu @@ -19,6 +19,21 @@ def main [] { mkdir ($root | path join extracted) call $target $root tar [-xzf /native.tgz -C /extracted --strip-components 1] | success | ignore check ((open --raw ($root | path join extracted sub file)) == 'hello from a nested file') 'world extracts native archive with strip-components' + let plain = (call $target $root tar [-cf /plain.tar -C /input/sub file] | success) + check ($plain.stdout == '') 'tar -cf' + let native_list = (^tar -tf ($root | path join plain.tar) | complete) + let world_list = (call $target $root tar [-tf /plain.tar]) + check ($native_list.stdout == $world_list.stdout and $native_list.exit_code == $world_list.exit_code) 'tar -tf matches native' + let native_verbose = (^tar -cvf ($root | path join verbose-native.tar) -C ($root | path join input sub) file | complete) + let world_verbose = (call $target $root tar [-cvf /verbose.tar -C /input/sub file]) + check ($native_verbose.stdout == $world_verbose.stdout and $native_verbose.exit_code == $world_verbose.exit_code and $world_verbose.stderr == '') 'tar create verbose named archive' + let stream = (call $target $root tar [-cvf - -C /input/sub file] | success) + check (($stream.stderr | str trim) == 'file') 'tar create verbose stream uses stderr' + let native_stream = ($stream.stdout | ^tar -tf - | complete) + check ($native_stream.stdout == $native_list.stdout and $native_stream.exit_code == 0) 'native reads tar stdout stream' + mkdir ($root | path join stream-extract) + call $target $root tar [-xf - -C /stream-extract] $stream.stdout | success | ignore + check ((open --raw ($root | path join stream-extract file)) == 'hello from a nested file') 'tar -xf stdin stream' let listed = (call $target $root tar [-tf /missing.tar]) let native_error = (^tar -tf ($root | path join missing.tar) | complete) check ($listed.exit_code == $native_error.exit_code) 'tar missing archive exit code' @@ -28,6 +43,11 @@ def main [] { let native_compressed = ('reverse payload' | ^gzip -c | complete) let decoded = (call $target $root gunzip [-c] $native_compressed.stdout | success) check ($decoded.stdout == 'reverse payload') 'world gunzip decodes native stream' + for tool in [gzip zcat] { + let flags = if $tool == 'gzip' { [-dc] } else { [] } + let decoded = (call $target $root $tool $flags $native_compressed.stdout | success) + check ($decoded.stdout == 'reverse payload') $'($tool) native gzip stream' + } call $target $root gzip [-k /input/sub/file] | success | ignore check (($root | path join input sub file.gz | path exists) and ($root | path join input sub file | path exists)) 'gzip -k keeps input' let conflict = (call $target $root gzip [/input/sub/file]) diff --git a/userland/fs-tools/Cargo.toml b/userland/fs-tools/Cargo.toml new file mode 100644 index 0000000..6fbd19b --- /dev/null +++ b/userland/fs-tools/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sausage-fs-tools" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "du" +path = "src/du.rs" + +[[bin]] +name = "stat" +path = "src/stat.rs" + +[[bin]] +name = "id" +path = "src/id.rs" + +[[bin]] +name = "nohup" +path = "src/nohup.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +chrono = { version = "0.4", default-features = false, features = ["std"] } +sausage-spawn = { path = "../spawn" } diff --git a/userland/fs-tools/README.md b/userland/fs-tools/README.md new file mode 100644 index 0000000..42e35c9 --- /dev/null +++ b/userland/fs-tools/README.md @@ -0,0 +1,36 @@ +# Filesystem and identity commands + +These commands supplement the default coreutils build. + +- `id` reports `uid=0(root) gid=0(root) groups=0(root)`. The world has one user. + `-u` and `-g` print zero. `-n` prints `root`, including `-un` and `-gn`. +- `du` sums apparent file sizes. Directories contribute zero bytes. + It supports `-s`, `-h`, `-a`, `-c`, `-d`/`--max-depth`, `-b`, and `-k`. + The default unit and `-k` use 1024 bytes. `-b` uses bytes. + Symlinks contribute their own size. The command does not follow them. + Native comparisons use `du --apparent-size` on files. + Native directory totals include host directory storage, which the world does not expose. +- `stat` supports `-c`/`--format`, `-L`, and multiple paths. + Formats: `%n %s %F %Y %y %X %x %N %% %a %A %U %G %u %g`. + Times use UTC. UID and GID are zero. User and group names are `root`. + Modes are fixed at `755` for directories and `644` otherwise. + The default output prints names, sizes, types, modes, identity, and timestamps. +- `nohup COMMAND [ARG...]` uses the existing process interface. + It starts an independent process group and returns the command's exit status. + The world has no terminal or SIGHUP. Non-terminal streams remain inherited. + It does not detach the command or create `nohup.out`, like native nohup with redirected streams. + +## Tests + +From `userland/`, use the executor's `CARGO_TARGET_DIR`: + +```sh +nix develop -c cargo build -p sausage-fs-tools --release --locked +nix develop -c nu --no-config-file fs-tools/test.nu +nix develop -c nu --no-config-file fs-tools/world-test.nu /tmp/s6-esbuild/app /tmp/s6-esbuild/esbuild-native +``` + +The world test requires the host binary and brush in the same target folder. +It also requires the pinned preview1 tools and the built archive tools. +The fixture contains S6's app, `check.mjs`, and its installed dependencies. +The test creates a fresh world under `/tmp`. It does not use the live world. diff --git a/userland/fs-tools/src/du.rs b/userland/fs-tools/src/du.rs new file mode 100644 index 0000000..b895cb1 --- /dev/null +++ b/userland/fs-tools/src/du.rs @@ -0,0 +1,77 @@ +//! Report apparent file sizes. Directories contribute no storage of their own. +use clap::Parser; +use std::{fs, io, path::PathBuf}; + +#[derive(Parser)] +struct Args { + #[arg(short = 's', long)] + summary: bool, + #[arg(short = 'h', long)] + human_readable: bool, + #[arg(short = 'a', long)] + all: bool, + #[arg(short = 'c', long)] + total: bool, + #[arg(short = 'd', long = "max-depth")] + depth: Option, + #[arg(short = 'b', long)] + bytes: bool, + #[arg(short = 'k')] + kibibytes: bool, + #[arg(default_value = ".")] + paths: Vec, +} +fn size(bytes: u64, args: &Args) -> String { + if args.human_readable && !args.bytes { + let mut value = bytes as f64; + let mut unit = 0; + while value.ceil() >= 1024.0 && unit < 6 { + value /= 1024.0; + unit += 1; + } + let suffix = ["", "K", "M", "G", "T", "P", "E"][unit]; + let rounded = (value * 10.0).ceil() / 10.0; + if unit > 0 && rounded < 10.0 { + format!("{rounded:.1}{suffix}") + } else { + format!("{}{suffix}", value.ceil() as u64) + } + } else if args.bytes { + bytes.to_string() + } else { + bytes.div_ceil(1024).to_string() + } +} +fn visit(path: &PathBuf, depth: usize, args: &Args) -> io::Result { + let meta = fs::symlink_metadata(path)?; + let mut bytes = if meta.is_dir() { 0 } else { meta.len() }; + if meta.is_dir() { + for entry in fs::read_dir(path)? { + bytes += visit(&entry?.path(), depth + 1, args)?; + } + } + if (depth == 0 || (!args.summary && (meta.is_dir() || args.all))) + && args.depth.is_none_or(|max| depth <= max) + { + println!("{}\t{}", size(bytes, args), path.display()); + } + Ok(bytes) +} +fn main() { + let args = Args::parse(); + let mut total = 0; + let mut failed = false; + for path in &args.paths { + match visit(path, 0, &args) { + Ok(bytes) => total += bytes, + Err(error) => { + eprintln!("du: {}: {error}", path.display()); + failed = true; + } + } + } + if args.total { + println!("{}\ttotal", size(total, &args)); + } + std::process::exit(i32::from(failed)); +} diff --git a/userland/fs-tools/src/id.rs b/userland/fs-tools/src/id.rs new file mode 100644 index 0000000..b556a88 --- /dev/null +++ b/userland/fs-tools/src/id.rs @@ -0,0 +1,26 @@ +//! Report the world's single user: root, with UID and GID zero. +use clap::Parser; +#[derive(Parser)] +struct Args { + #[arg(short = 'u', long, conflicts_with = "group")] + user: bool, + #[arg(short = 'g', long)] + group: bool, + #[arg(short = 'n', long)] + name: bool, + #[arg(value_parser = ["root", "0"])] + account: Option, +} +fn main() { + let args = Args::parse(); + println!( + "{}", + if args.name { + "root" + } else if args.user || args.group { + "0" + } else { + "uid=0(root) gid=0(root) groups=0(root)" + } + ); +} diff --git a/userland/fs-tools/src/nohup.rs b/userland/fs-tools/src/nohup.rs new file mode 100644 index 0000000..7e976ac --- /dev/null +++ b/userland/fs-tools/src/nohup.rs @@ -0,0 +1,27 @@ +//! Run a command in an independent process group. The world has no terminal or SIGHUP. +use sausage_spawn::Command; +fn main() { + let mut args = std::env::args().skip(1).peekable(); + if args.peek().is_some_and(|arg| arg == "--") { + args.next(); + } + let Some(program) = args.next() else { + eprintln!("nohup: missing command"); + std::process::exit(125); + }; + let mut command = Command::new(program); + command.args(args).new_group(); + std::process::exit(match command.status() { + Ok(code) => code, + Err(error) => { + eprintln!("nohup: {error}"); + if error.kind() == std::io::ErrorKind::NotFound + || error.to_string().contains("not found") + { + 127 + } else { + 126 + } + } + }); +} diff --git a/userland/fs-tools/src/stat.rs b/userland/fs-tools/src/stat.rs new file mode 100644 index 0000000..f41a607 --- /dev/null +++ b/userland/fs-tools/src/stat.rs @@ -0,0 +1,108 @@ +//! Print portable filesystem metadata and the world's fixed root identity. +use chrono::{DateTime, Utc}; +use clap::Parser; +use std::{fs, io, path::PathBuf, time::SystemTime}; +#[derive(Parser)] +struct Args { + #[arg(short = 'c', long = "format")] + format: Option, + #[arg(short = 'L', long)] + dereference: bool, + #[arg(required = true)] + paths: Vec, +} +fn timestamp(value: io::Result, seconds: bool) -> String { + match value { + Ok(time) => { + let date: DateTime = time.into(); + if seconds { + date.timestamp().to_string() + } else { + date.format("%Y-%m-%d %H:%M:%S%.9f +0000").to_string() + } + } + Err(_) => { + if seconds { + "0".into() + } else { + "-".into() + } + } + } +} +fn render(path: &PathBuf, meta: &fs::Metadata, format: &str) -> io::Result { + let mut out = String::new(); + let mut chars = format.chars(); + while let Some(ch) = chars.next() { + if ch != '%' { + out.push(ch); + continue; + } + let field = chars.next().unwrap_or('%'); + let value = match field { + '%' => "%".into(), + 'n' => path.display().to_string(), + 'N' => { + if meta.is_symlink() { + format!( + "'{}' -> '{}'", + path.display(), + fs::read_link(path)?.display() + ) + } else { + format!("'{}'", path.display()) + } + } + 's' => meta.len().to_string(), + 'F' => if meta.is_dir() { + "directory" + } else if meta.is_symlink() { + "symbolic link" + } else if meta.len() == 0 { + "regular empty file" + } else { + "regular file" + } + .into(), + 'Y' => timestamp(meta.modified(), true), + 'y' => timestamp(meta.modified(), false), + 'X' => timestamp(meta.accessed(), true), + 'x' => timestamp(meta.accessed(), false), + 'a' => if meta.is_dir() { "755" } else { "644" }.into(), + 'A' => if meta.is_dir() { + "drwxr-xr-x" + } else if meta.is_symlink() { + "lrw-r--r--" + } else { + "-rw-r--r--" + } + .into(), + 'u' | 'g' => "0".into(), + 'U' | 'G' => "root".into(), + _ => return Err(io::Error::other(format!("unsupported format %{field}"))), + }; + out.push_str(&value); + } + Ok(out) +} +fn main() { + let args = Args::parse(); + let format = args.format.as_deref().unwrap_or(" File: %N\n Size: %s\tType: %F\nAccess: (%a/%A) Uid: (%u/%U) Gid: (%g/%G)\nAccess: %x\nModify: %y"); + let mut failed = false; + for path in &args.paths { + let result = if args.dereference { + fs::metadata(path) + } else { + fs::symlink_metadata(path) + } + .and_then(|meta| render(path, &meta, format)); + match result { + Ok(text) => println!("{text}"), + Err(e) => { + eprintln!("stat: {}: {e}", path.display()); + failed = true; + } + } + } + std::process::exit(i32::from(failed)); +} diff --git a/userland/fs-tools/test.nu b/userland/fs-tools/test.nu new file mode 100644 index 0000000..1afbc3d --- /dev/null +++ b/userland/fs-tools/test.nu @@ -0,0 +1,65 @@ +#!/usr/bin/env nu +# Compare portable metadata, apparent file sizes, and the fixed root identity. +def check [ok: bool, message: string] { + if not $ok { error make {msg: $message} } + print $'ok - ($message)' +} +def call [root: path, tool: string, args: list] { + ^wasmtime run -S cli-exit-with-code=y --dir $root ($env.CARGO_TARGET_DIR | path join wasm32-wasip2 release $'($tool).wasm') ...$args | complete +} +def main [] { + let root = (^mktemp -d /tmp/t42-fs-XXXXXXXX | str trim) + mkdir ($root | path join tree sub) + 'hello' | save ($root | path join tree file) + 'world!' | save ($root | path join tree sub nested) + let file = ($root | path join tree file) + for flags in [[-b] [-k] [-sh] [-ab] [-bc]] { + let native = (^du --apparent-size ...$flags $file | complete) + let wasm = (call $root du ($flags | append $file)) + check ($native.stdout == $wasm.stdout and $native.exit_code == $wasm.exit_code) $'du ($flags | str join " ")' + } + let tree = ($root | path join tree) + let total = (call $root du [-sb $tree]) + check ($total.stdout == $'11(char tab)($tree)(char newline)' and $total.exit_code == 0) 'du sums files without directory storage' + let depth = (call $root du [--max-depth 0 -b $tree]) + check ($depth.stdout == $total.stdout and $depth.exit_code == 0) 'du max-depth' + let all = (call $root du [-ab $tree]) + check (($all.stdout | lines | length) == 4 and $all.exit_code == 0) 'du all files and directories' + let total_all = (call $root du [-sbc $tree]) + check ($total_all.stdout == $'11(char tab)($tree)(char newline)11(char tab)total(char newline)') 'du total' + for flags in [[] [-u] [-g] [-un] [-gn]] { + let native = (^id ...$flags root | complete) + let wasm = (call $root id $flags) + check ($native.stdout == $wasm.stdout and $native.exit_code == $wasm.exit_code) $'id ($flags | str join " ")' + } + check ((call $root id [-n]).stdout == $'root(char newline)') 'id name alone' + for format in ['%n %s %F' '%Y %X' '%y %x' '%N' '%%'] { + let native = (with-env {TZ: UTC} { ^stat -c $format $file | complete }) + let wasm = (call $root stat [-c $format $file]) + check ($native.stdout == $wasm.stdout and $native.exit_code == $wasm.exit_code) $'stat ($format)' + } + check ((call $root stat [-c '%a %A %u %g %U %G' $file]).stdout == $'644 -rw-r--r-- 0 0 root root(char newline)') 'stat virtual mode and identity' + check ((call $root stat [-c '%a %A %F' $tree]).stdout == $'755 drwxr-xr-x directory(char newline)') 'stat directory mode' + let link = ($root | path join link) + ^ln -s tree/file $link + for flags in [[-c '%n %s %F %N'] [-L -c '%n %s %F %N']] { + let native = (^stat ...$flags $link | complete) + let wasm = (call $root stat ($flags | append $link)) + check ($native.stdout == $wasm.stdout and $native.exit_code == $wasm.exit_code) $'stat symlink ($flags | str join " ")' + } + let default_stat = (call $root stat [$file]) + check ($default_stat.exit_code == 0 and ($default_stat.stdout | str contains 'root')) 'stat default output' + let sized = ($root | path join sized) + for bytes in [1024 1025 10138 11000 1048575 1048576] { + ^truncate -s $bytes $sized + let native = (^du --apparent-size -sh $sized | complete) + let wasm = (call $root du [-sh $sized]) + check ($native.stdout == $wasm.stdout and $native.exit_code == $wasm.exit_code) $'du human size ($bytes)' + } + for tool in [du stat] { + let native = (if $tool == 'du' { ^du --apparent-size /t42-does-not-exist | complete } else { ^stat /t42-does-not-exist | complete }) + let wasm = (call $root $tool [/t42-does-not-exist]) + check ($wasm.exit_code == $native.exit_code) $'($tool) missing path status' + } + print $'evidence: ($root)' +} diff --git a/userland/fs-tools/world-test.nu b/userland/fs-tools/world-test.nu new file mode 100644 index 0000000..c055946 --- /dev/null +++ b/userland/fs-tools/world-test.nu @@ -0,0 +1,53 @@ +#!/usr/bin/env nu +# Exercise the new commands through the real host and brush shell. +# The S6 fixture must contain app.tsx and its pinned node_modules. +def check [ok: bool, message: string] { + if not $ok { error make {msg: $message} } + print $'ok - ($message)' +} +def main [fixture: path, native_esbuild: path] { + let target = ($env.CARGO_TARGET_DIR | path expand) + let root = (^mktemp -d /tmp/t42-world-XXXXXXXX | str trim) + let bundle = ($root | path join bundle) + mkdir ($bundle | path join bin) ($bundle | path join src) + for tool in [du stat id nohup tar gzip gunzip zcat] { + cp ($target | path join wasm32-wasip2 release $'($tool).wasm') ($bundle | path join bin $'($tool).wasm') + } + cp ($target | path join wasm32-wasip2 release brush.wasm) ($bundle | path join bin sh.wasm) + cp ($target | path join awk.wasm) ($bundle | path join bin awk.wasm) + cp ($target | path join esbuild.wasm) ($bundle | path join bin esbuild.wasm) + cp ($env.FILE_PWD | path join .. qjs qjs.wasm) ($bundle | path join bin qjs.wasm) + cp -r $fixture ($bundle | path join src app) + let host = ($target | path join debug sausage) + let world = ($root | path join world) + let script = 'id; id -un; printf hello > /payload; du -b /payload; stat -c "%n %s %F %U %G" /payload; printf "2 a\n3 b\n" | awk "{sum += \$1} END {print sum}"; tar -czf /world.tgz -C / payload; qjs -e "console.log(6*7)"; esbuild /src/app/app.tsx --bundle --minify --format=iife --platform=browser --jsx=automatic --jsx-import-source=preact --legal-comments=none --log-level=error --outfile=/app.js' + let result = (^$host sh $world --userland $bundle -c $script | complete) + print $result.stdout + if $result.stderr != '' { print --stderr $result.stderr } + check ($result.exit_code == 0) 'real world shell and esbuild' + check ($result.stdout | str contains 'uid=0(root) gid=0(root) groups=0(root)') 'world root identity' + check ($result.stdout | str contains '/payload 5 regular file root root') 'world portable stat' + check ($result.stdout | str contains '42') 'world QuickJS' + let native_bundle = ($root | path join native.js) + let native = (do { cd ($bundle | path join src app); ^$native_esbuild app.tsx --bundle --minify --format=iife --platform=browser --jsx=automatic --jsx-import-source=preact --legal-comments=none --log-level=error $'--outfile=($native_bundle)' } | complete) + let world_bundle = ($world | path join world app.js) + check ($native.exit_code == 0 and (open --raw $native_bundle) == (open --raw $world_bundle)) 'world esbuild matches native bytes' + let render = (^node ($world | path join world src app check.mjs) $world_bundle | complete) + print $render.stdout + check ($render.exit_code == 0) 'world esbuild bundle renders S6 app' + let extracted = ($root | path join extracted) + mkdir $extracted + ^tar -xzf ($world | path join world world.tgz) -C $extracted + check ($env.LAST_EXIT_CODE == 0 and (open --raw ($extracted | path join payload)) == 'hello') 'native extracts real world archive' + let native = ($root | path join native) + mkdir $native + 'from native' | save ($native | path join native-file) + ^tar -czf ($world | path join world native.tgz) -C $native native-file + let reverse = (^$host sh $world -c 'tar -xzf /native.tgz -C /; qjs --std -e "console.log(std.loadFile(\"/native-file\"))"' | complete) + check ($reverse.exit_code == 0 and ($reverse.stdout | str trim) == 'from native') 'world extracts native archive' + let nohup = (^$host sh $world -c 'nohup qjs --std -e "std.exit(7)"' | complete) + check ($nohup.exit_code == 7) 'nohup preserves child status' + let missing = (^$host sh $world -c 'nohup t42-missing-command' | complete) + check ($missing.exit_code == 127) 'nohup missing command status' + print $'evidence: ($root)' +}