userland: add portable du, stat, root id, and nohup

This commit is contained in:
2026-11-07 11:16:55 +00:00
parent d79b2bc2e1
commit dc6d90b0d3
13 changed files with 463 additions and 5 deletions
+9
View File
@@ -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"
+1 -1
View File
@@ -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]
+1
View File
@@ -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.
+15 -4
View File
@@ -10,7 +10,7 @@ fn append<W: Write>(
builder: &mut tar::Builder<W>,
source: &Path,
name: &Path,
verbose: bool,
verbose: Option<bool>,
) -> io::Result<()> {
let meta = std::fs::symlink_metadata(source)?;
let mut header = tar::Header::new_gnu();
@@ -22,8 +22,13 @@ fn append<W: Write>(
.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<W: Write>(
}
Ok(())
}
fn create<W: Write>(writer: W, cwd: &Path, paths: &[String], verbose: bool) -> io::Result<W> {
fn create<W: Write>(
writer: W,
cwd: &Path,
paths: &[String],
verbose: Option<bool>,
) -> io::Result<W> {
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<dyn std::error::Error>> {
if paths.is_empty() {
return Err("refusing to create an empty archive".into());
}
let verbose = verbose.then_some(archive != "-");
let writer: Box<dyn Write> = if archive == "-" {
Box::new(io::stdout())
} else {
+20
View File
@@ -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])
+25
View File
@@ -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" }
+36
View File
@@ -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.
+77
View File
@@ -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<usize>,
#[arg(short = 'b', long)]
bytes: bool,
#[arg(short = 'k')]
kibibytes: bool,
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
}
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<u64> {
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));
}
+26
View File
@@ -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<String>,
}
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)"
}
);
}
+27
View File
@@ -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
}
}
});
}
+108
View File
@@ -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<String>,
#[arg(short = 'L', long)]
dereference: bool,
#[arg(required = true)]
paths: Vec<PathBuf>,
}
fn timestamp(value: io::Result<SystemTime>, seconds: bool) -> String {
match value {
Ok(time) => {
let date: DateTime<Utc> = 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<String> {
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));
}
+65
View File
@@ -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<string>] {
^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)'
}
+53
View File
@@ -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)'
}