From ffee43b33c7c3bb6a9211f0987cf1013179eb410 Mon Sep 17 00:00:00 2001 From: Surma Date: Sun, 8 Nov 2026 00:58:30 +0000 Subject: [PATCH] Add world CLI compatibility for Git, xargs, devices and tool aliases --- host/src/world.rs | 19 +++++- host/tests/p1.rs | 39 ++++++++++-- userland/findutils/src/xargs.rs | 109 ++++++++++++++++++++++++++++---- userland/findutils/test.nu | 31 +++++++++ userland/git/README.md | 3 +- userland/git/src/changes.rs | 107 +++++++++++++++++++++++++++++++ userland/git/src/lib.rs | 10 ++- userland/git/src/main.rs | 31 ++++++++- userland/git/test.nu | 30 +++++++++ vfs/src/generated.rs | 79 ++++++++++++++++++++--- vfs/src/lib.rs | 2 +- vfs/tests/devices.rs | 47 ++++++++++++++ 12 files changed, 475 insertions(+), 32 deletions(-) create mode 100644 userland/findutils/test.nu create mode 100644 vfs/tests/devices.rs diff --git a/host/src/world.rs b/host/src/world.rs index a08c29e..89aaa12 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -3,7 +3,7 @@ //! Layout of a world directory: //! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it. //! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it. -use sausage_vfs::{Generated, Host, Layer, Memory, Mounts, Process}; +use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process}; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -42,6 +42,8 @@ impl World { } /// Copy an optional bundle into a new world. Existing worlds retain their programs. + /// Each `.wasm` tool keeps its independent file and gains a relative `/bin/` symlink. + /// `/dev` exposes generated null, zero, and random byte devices in every world. pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result { let root_dir = dir.join("world"); let host_dir = dir.join("host"); @@ -51,6 +53,16 @@ impl World { for folder in ["bin", "src"] { copy_tree(&bundle.join(folder), &root_dir.join(folder))?; } + // Keep independent .wasm files and expose conventional executable names. + for entry in std::fs::read_dir(root_dir.join("bin"))? { + let path = entry?.path(); + if path.extension().is_some_and(|ext| ext == "wasm") { + let alias = path.with_extension(""); + if std::fs::symlink_metadata(&alias).is_err() { + std::os::unix::fs::symlink(path.file_name().unwrap(), alias)?; + } + } + } } std::fs::create_dir_all(&host_dir)?; let mut mounts = Mounts::default(); @@ -83,6 +95,11 @@ impl World { }); mounts.mount("/proc", Arc::new(proc), true); mounts.mount("/run", Arc::new(Memory::default()), false); + let mut devices = Generated::default(); + devices.insert_device("/null", Device::Null); + devices.insert_device("/zero", Device::Zero); + devices.insert_device("/urandom", Device::Random); + mounts.mount("/dev", Arc::new(devices), false); let journal = sausage_vfs::journal::Journal::open(&host_dir.join("undo"), Arc::new(mounts))?; let root = journal.layer(sausage_vfs::journal::Tag::process( diff --git a/host/tests/p1.rs b/host/tests/p1.rs index ef4c22d..5912a60 100644 --- a/host/tests/p1.rs +++ b/host/tests/p1.rs @@ -22,9 +22,15 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { use std::os::unix::fs::MetadataExt; for entry in std::fs::read_dir(dir.path().join("world/bin"))? { let entry = entry?; - assert_eq!(entry.path().extension().unwrap(), "wasm"); - assert_eq!(entry.metadata()?.nlink(), 1); - assert!(!entry.file_type()?.is_symlink()); + if entry.file_type()?.is_symlink() { + assert_eq!( + std::fs::read_link(entry.path())?, + PathBuf::from(format!("{}.wasm", entry.file_name().to_string_lossy())) + ); + } else { + assert_eq!(entry.path().extension().unwrap(), "wasm"); + assert_eq!(entry.metadata()?.nlink(), 1); + } } assert!(world.root.stat("/usr/local/bin", true).await?.directory); let writable = world @@ -46,7 +52,7 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { })?; use sausage::exec::{Command, Options, Stream}; for (script, expected) in [ - ("command -v ls", "/bin/ls.wasm\n"), + ("command -v ls", "/bin/ls\n"), ("ls", "bin"), ("git --version", "git version"), ("printf 'a\\n' | xargs echo", "a\n"), @@ -67,6 +73,31 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> { assert_eq!(code, 0, "{script}"); assert!(String::from_utf8(out)?.contains(expected), "{script}"); } + for (script, expected) in [ + ( + "echo x > /dev/null && cat < /dev/null && ls /bin/git && printf 'a\\nb\\n' | xargs -I{} echo [{}]", + "/bin/git\n[a]\n[b]\n", + ), + ("test -x /bin/git && echo executable", "executable\n"), + ("head -c 4 /dev/zero | wc -c", "4\n"), + ("head -c 4 /dev/urandom | wc -c", "4\n"), + ("echo x > /dev/null 2>&1; cat < /dev/null", ""), + ] { + let mut out = Vec::new(); + let code = exec + .exec( + Command::Shell(script.into()), + Options::default(), + &mut |stream, bytes| { + if matches!(stream, Stream::Stdout) { + out.extend_from_slice(bytes); + } + }, + ) + .await?; + assert_eq!(code, 0, "{script}"); + assert_eq!(out, expected.as_bytes(), "{script}"); + } let mut history = Vec::new(); assert_eq!( exec.exec( diff --git a/userland/findutils/src/xargs.rs b/userland/findutils/src/xargs.rs index fbc43b2..fc5c2f7 100644 --- a/userland/findutils/src/xargs.rs +++ b/userland/findutils/src/xargs.rs @@ -1,39 +1,122 @@ //! Build sequential child commands from quoted or NUL-delimited input. -use std::io::{self, Read}; use sausage_spawn::{Command, Stdio}; +use std::io::{self, Read}; fn run() -> Result> { let args: Vec<_> = std::env::args().skip(1).collect(); let mut i = 0; let mut count = usize::MAX; let mut null = false; let mut no_empty = false; + let mut replace = None; while i < args.len() { match args[i].as_str() { + "-I" => { + i += 1; + replace = Some(args.get(i).ok_or("missing -I argument")?.clone()); + count = 1; + } + arg if arg.starts_with("-I") => { + replace = Some(arg[2..].to_owned()); + count = 1; + } "-0" => null = true, "-r" => no_empty = true, - "-n" => { i += 1; count = args.get(i).ok_or("missing -n argument")?.parse()?; } - "--" => { i += 1; break; } + "-n" => { + i += 1; + count = args.get(i).ok_or("missing -n argument")?.parse()?; + } + "--" => { + i += 1; + break; + } arg if arg.starts_with("-n") => count = arg[2..].parse()?, arg if arg.starts_with('-') => return Err(format!("unsupported option: {arg}").into()), _ => break, } i += 1; } - if count == 0 { return Err("-n must be positive".into()); } - let argv = if i == args.len() { vec!["echo".into()] } else { args[i..].to_vec() }; - let mut input = String::new(); io::stdin().read_to_string(&mut input)?; - let values = if null { input.split_terminator('\0').map(str::to_owned).collect() } - else { shlex::split(&input).ok_or("unmatched quote in input")? }; - if values.is_empty() && no_empty { return Ok(0); } + if count == 0 { + return Err("-n must be positive".into()); + } + let argv = if i == args.len() { + vec!["echo".into()] + } else { + args[i..].to_vec() + }; + let mut input = String::new(); + io::stdin().read_to_string(&mut input)?; + let values = if null { + input.split_terminator('\0').map(str::to_owned).collect() + } else if replace.is_some() { + input + .split_terminator('\n') + .map(|line| line.trim_start_matches([' ', '\t'])) + .filter(|line| !line.is_empty()) + .map(replacement) + .collect::, _>>()? + } else { + shlex::split(&input).ok_or("unmatched quote in input")? + }; + if values.is_empty() && (no_empty || replace.is_some()) { + return Ok(0); + } let mut result = 0; - let batches = if values.is_empty() { vec![&[][..]] } else { values.chunks(count).collect() }; + let batches = if values.is_empty() { + vec![&[][..]] + } else { + values.chunks(count).collect() + }; for batch in batches { - match Command::new(&argv[0]).args(&argv[1..]).args(batch).stdin(Stdio::Null).status() { - Ok(0) => (), Ok(255) => return Ok(124), Ok(_) => result = 123, + let command = if let Some(ref token) = replace { + argv.iter() + .map(|arg| arg.replace(token, &batch.join(" "))) + .collect::>() + } else { + argv.iter().cloned().chain(batch.iter().cloned()).collect() + }; + match Command::new(&command[0]) + .args(&command[1..]) + .stdin(Stdio::Null) + .status() + { + Ok(0) => (), + Ok(255) => return Ok(124), + Ok(_) => result = 123, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(127), Err(_) => return Ok(126), } } Ok(result) } -fn main() { std::process::exit(match run() { Ok(code) => code, Err(e) => { eprintln!("xargs: {e}"); 1 } }); } +fn replacement(line: &str) -> Result> { + let mut result = String::new(); + let mut quote = None; + let mut escaped = false; + for ch in line.chars() { + if escaped { + result.push(ch); + escaped = false; + } else if ch == '\\' && quote != Some('\'') { + escaped = true; + } else if quote == Some(ch) { + quote = None; + } else if quote.is_none() && (ch == '\'' || ch == '"') { + quote = Some(ch); + } else { + result.push(ch); + } + } + if quote.is_some() || escaped { + return Err("unmatched quote or escape in input".into()); + } + Ok(result) +} +fn main() { + std::process::exit(match run() { + Ok(code) => code, + Err(e) => { + eprintln!("xargs: {e}"); + 1 + } + }); +} diff --git a/userland/findutils/test.nu b/userland/findutils/test.nu new file mode 100644 index 0000000..9bb0c8c --- /dev/null +++ b/userland/findutils/test.nu @@ -0,0 +1,31 @@ +#!/usr/bin/env nu +# Compare xargs stdout and exit codes with GNU xargs in a real world. +def main [host: path, bundle: path] { + let world = (mktemp -d) + mkdir ($world | path join world) + cp -r ($bundle | path join bin) ($world | path join world bin) + let nul = (char nul) + let cases = [ + {input: "a\nb\n", args: [-I{} printf '<%s>\n' '{}']} + {input: "a b\nc d\n", args: [-I REPL printf '<%s>\n' preREPLpost REPL]} + {input: " 'a b'\nc\\ d\n\n", args: [-I{} printf '<%s>\n' '{}']} + {input: $"'a b'($nul)two\nlines($nul)", args: ['-0' -I{} printf '<%s>\n' '{}']} + {input: $"a b($nul)c($nul)d($nul)", args: ['-0' -n 2 printf '<%s>\n']} + {input: "a b c\n", args: [-n 2 echo]} + {input: "'a b' c\n", args: [-n1 printf '<%s>\n']} + {input: '', args: [echo]} + {input: '', args: [-r echo]} + {input: '', args: [-I{} echo '{}']} + {input: "a\n", args: [sh -c 'exit 7']} + ] + for case in $cases { + $case.input | save -f ($world | path join world input) + let native = ($case.input | ^xargs ...$case.args | complete) + let command = ($case.args | each {|arg| $arg | into string | to json -r} | str join ' ') + let guest = (^$host sh $world -c $"xargs ($command) < /input" | complete) + if $native.stdout != $guest.stdout or $native.exit_code != $guest.exit_code { + error make {msg: $"xargs mismatch ($case): native=($native) guest=($guest). World: ($world)"} + } + print $"PASS xargs ($case.args)" + } +} diff --git a/userland/git/README.md b/userland/git/README.md index 13e2f39..2c54f1d 100644 --- a/userland/git/README.md +++ b/userland/git/README.md @@ -56,12 +56,13 @@ It checks multiple revisions, detached status, branch deletion messages, and mer It also checks these forms: - `status -sb` and `status --porcelain=v1`. -- `log -n`, `--oneline`, `-p`, `--stat`, and one literal path after `--`. +- `log -N`, `log -n N`, `--max-count=N`, `--oneline`, `-p`, `--stat`, and one literal path after `--`. - `log --format` and `--pretty=format:` with `%H %h %s %an %ae %ad %d`. - `log A..B`, `--name-only`, `--no-renames`, and `--diff-merges=first-parent`. - `%x00`, `%x1e`, and `%(trailers:key=Sausage-Conversation,valueonly)` in log formats. - `show --stat`, `show HEAD:path`, and merge commits without a combined diff. - `diff HEAD~1`, `diff A..B`, `--name-only`, `--name-status`, and one literal path after `--`. +- `diff --check` and `diff --cached --check` for added whitespace errors and conflict markers. - `branch -a`, `-d`, `-D`, `-m`, and `--show-current`. - `checkout -b`, `switch -c`, and `merge --no-ff`. - `rev-parse --short HEAD`, `ls-files`, and `config --get`. diff --git a/userland/git/src/changes.rs b/userland/git/src/changes.rs index 6b2f91f..a3d6002 100644 --- a/userland/git/src/changes.rs +++ b/userland/git/src/changes.rs @@ -618,6 +618,113 @@ fn unified(a: &str, b: &str, context: usize) -> String { out } +/// Git's default whitespace checks apply only to added lines. +pub fn check( + repo: &gix::Repository, + diffs: &[FileDiff], + revision: Option<&str>, + cached: bool, +) -> Result { + let snapshot = if let Some(rev) = revision { + Some(tree(repo, rev)?) + } else if cached { + Some(index(repo)?.0) + } else { + None + }; + let mut failed = false; + for diff in diffs.iter().filter(|diff| !diff.binary) { + let mut number = 0; + let mut added = BTreeSet::new(); + for line in diff.patch.split_terminator('\n') { + if line.starts_with("@@ ") { + number = line + .split_whitespace() + .nth(2) + .unwrap() + .trim_start_matches('+') + .split(',') + .next() + .unwrap() + .parse::() + .unwrap(); + } else if number > 0 && line.starts_with('+') { + let text = &line[1..]; + if text.trim_ascii().is_empty() { + added.insert(number); + } + let indent = text + .chars() + .take_while(|ch| *ch == ' ' || *ch == '\t') + .collect::(); + let mut errors = Vec::new(); + if text.ends_with(|ch: char| ch.is_ascii_whitespace()) { + errors.push("trailing whitespace"); + } + if !text.trim_ascii().is_empty() && indent.contains(" \t") { + errors.push("space before tab in indent"); + } + if text.starts_with("<<<<<<< ") || text == "=======" || text.starts_with(">>>>>>> ") + { + println!("{}:{number}: leftover conflict marker", diff.path); + failed = true; + } + if !errors.is_empty() { + println!("{}:{number}: {}.", diff.path, errors.join(", ")); + println!("{line}"); + failed = true; + } + number += 1; + } else if number > 0 && line.starts_with(' ') { + number += 1; + } + } + if added.is_empty() { + continue; + } + let bytes = if let Some(ref snapshot) = snapshot { + blob(repo, snapshot.get(diff.path.as_bytes()))? + } else if diff.status == Kind::Deleted { + Vec::new() + } else { + let file = repo.workdir().unwrap().join(&diff.path); + if std::fs::symlink_metadata(&file) + .map_err(|e| error("diff check", e))? + .file_type() + .is_symlink() + { + gix::path::into_bstr(std::fs::read_link(&file).map_err(|e| error("diff check", e))?) + .map_err(|e| error("diff check", e))? + .into_owned() + .to_vec() + } else { + std::fs::read(file).map_err(|e| error("diff check", e))? + } + }; + let text = String::from_utf8_lossy(&bytes); + let mut lines: Vec<_> = text.lines().collect(); + if !text.ends_with('\n') + && lines + .last() + .is_some_and(|line| line.trim_ascii().is_empty()) + { + lines.pop(); + } + let first = lines + .iter() + .enumerate() + .rev() + .take_while(|(_, line)| line.trim_ascii().is_empty()) + .filter_map(|(i, _)| added.contains(&(i + 1)).then_some(i + 1)) + .min(); + if let Some(line) = first { + println!("{}:{line}: new blank line at EOF.", diff.path); + failed = true; + } + } + Ok(if failed { 2 } else { 0 }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/userland/git/src/lib.rs b/userland/git/src/lib.rs index f4bcf27..d881b01 100644 --- a/userland/git/src/lib.rs +++ b/userland/git/src/lib.rs @@ -207,7 +207,7 @@ enum Command { diff_merges: Option, #[arg(long)] oneline: bool, - #[arg(short = 'n')] + #[arg(short = 'n', long = "max-count")] limit: Option, #[arg(long)] format: Option, @@ -222,6 +222,8 @@ enum Command { paths: Vec, }, Diff { + #[arg(long)] + check: bool, #[arg(long)] numstat: bool, #[arg(long)] @@ -892,6 +894,7 @@ pub fn run(cli: Cli) -> Result { } } Command::Diff { + check, numstat, no_renames, diff_filter, @@ -919,13 +922,16 @@ pub fn run(cli: Cli) -> Result { changes::DiffParams { repo: path, from, - to, + to: to.clone(), staged: Some(cached), path: paths.first().cloned(), context: None, }, !no_renames, )?; + if check { + return changes::check(&repository, &diffs, to.as_deref(), cached); + } if name_only || name_status { for diff in diffs { if name_status { diff --git a/userland/git/src/main.rs b/userland/git/src/main.rs index b520446..67d5b7a 100644 --- a/userland/git/src/main.rs +++ b/userland/git/src/main.rs @@ -1,11 +1,38 @@ //! Parse Git CLI arguments and return Git-compatible usage and operation exit codes. use clap::{Parser, error::ErrorKind}; fn main() { - if matches!(std::env::args().nth(1).as_deref(), Some("--version" | "version")) { + if matches!( + std::env::args().nth(1).as_deref(), + Some("--version" | "version") + ) { println!("git version {}.sausage", env!("CARGO_PKG_VERSION")); exit(0); } - let cli = match git::Cli::try_parse() { + let mut args: Vec<_> = std::env::args_os().collect(); + let mut command = 1; + while command < args.len() && args[command].to_string_lossy().starts_with('-') { + command += if args[command] == "-C" || args[command] == "-c" { + 2 + } else { + 1 + }; + } + if args.get(command).is_some_and(|arg| arg == "log") { + let mut i = command + 1; + while i < args.len() && args[i] != "--" { + let arg = args[i].to_string_lossy(); + if let Some(count) = arg + .strip_prefix('-') + .filter(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())) + { + let count = count.to_owned(); + args.splice(i..=i, ["-n".into(), count.into()]); + i += 1; + } + i += 1; + } + } + let cli = match git::Cli::try_parse_from(args) { Ok(cli) => cli, Err(error) => { let code = if !error.use_stderr() { diff --git a/userland/git/test.nu b/userland/git/test.nu index 97ffa39..0bb33bb 100644 --- a/userland/git/test.nu +++ b/userland/git/test.nu @@ -71,6 +71,11 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [add .], compare: true} {args: [commit -m topic], compare: true} {args: [rev-parse HEAD HEAD~1], compare: true} + {args: [log -1], compare: true} + {args: [log -2], compare: true} + {args: [log -n 2], compare: true} + {args: [log --oneline -5], compare: true} + {args: [log --max-count=1 --oneline], compare: true} {args: [diff HEAD~1], compare: true} {args: [diff HEAD~1..HEAD], compare: true} {args: [merge-base HEAD~1 HEAD], compare: true} @@ -311,6 +316,18 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t {args: [log -n 1 --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00'], compare: true} {args: [commit --allow-empty -m no-trailer], compare: true, state: true} {args: [log --name-only --no-renames --diff-merges=first-parent '--format=%x1e%H%x00%s%x00%(trailers:key=Sausage-Conversation,valueonly)%x00' HEAD~3..HEAD], compare: true} + {args: [diff --check], compare: true} + {args: [diff --cached --check], compare: true} + {write: 'clean\nbad \n indent\n\n', file: 'file.txt'} + {args: [diff --check], compare: true, exit: 2} + {args: [add file.txt], compare: true} + {args: [diff --cached --check], compare: true, exit: 2} + {write: 'clean\n', file: 'file.txt'} + {args: [diff --check], compare: true} + {args: [add file.txt], compare: true} + {args: [diff --cached --check], compare: true} + {write: 'clean\n indent \n<<<<<<< HEAD\n=======\n>>>>>>> other\n', file: 'file.txt'} + {args: [diff --check], compare: true, exit: 2} {args: [status --bad-option], compare: true, exit: 129} {args: [not-a-command], compare: true, exit: 1} ] @@ -419,6 +436,19 @@ def main [component: path, server: path = '/tmp/p1-git/server-target/debug/git-t let equal = ($native_result.exit_code == 0 and $result.exit_code == 129 and $result.stdout == '' and ($result.stderr | str contains 'usage')) print $'show combined merge: (if $equal {"PASS documented usage"} else {"FAIL documented usage"}) native=($native_result.exit_code) wasi=($result.exit_code)' if not $equal { $failures += 1; print ($result | to json) } + # Whitespace checks read symlink text, not the target of a dangling symlink. + ^ln -sfn missing ($native | path join check-link) + ^git -C $native add check-link + for target in ['missing ' ' '] { + ^ln -sfn $target ($native | path join check-link) + for args in [[diff --check] [add check-link] [diff --cached --check]] { + let expected = (^git -C $native ...$args | complete) + let actual = (^wasmtime run -S http=y -S cli-exit-with-code=y --dir $'($native)::/links' $component -C /links ...$args | complete) + let equal = ($expected.stdout == $actual.stdout and $expected.exit_code == $actual.exit_code) + print $'symlink ($args): (if $equal {"PASS"} else {"FAIL"})' + if not $equal { $failures += 1; print ($expected | to json); print ($actual | to json) } + } + } if $failures > 0 {error make {msg: $'($failures) workflow comparisons failed'}} } } catch {|err| job kill $server_job; error make {msg: $err.msg} } diff --git a/vfs/src/generated.rs b/vfs/src/generated.rs index 4fc569d..7baddbc 100644 --- a/vfs/src/generated.rs +++ b/vfs/src/generated.rs @@ -1,4 +1,4 @@ -//! Read-only generated files. Each open captures an independent finite snapshot. +//! Generated snapshots and standard byte devices. Device writes have no persistent effect. use crate::*; use std::{collections::BTreeMap, io::ErrorKind as E}; type Generator = Arc Result> + Send + Sync>; @@ -6,7 +6,7 @@ type Generator = Arc Result> + Send + Sync>; /// Generators must be cheap and must not block. Stat can invoke them as well as open. #[derive(Default)] pub struct Generated { - files: BTreeMap, + files: BTreeMap, } impl Generated { pub fn insert( @@ -14,7 +14,13 @@ impl Generated { path: &str, generate: impl Fn() -> Result> + Send + Sync + 'static, ) { - self.files.insert(resolve("/", path), Arc::new(generate)); + self.files + .insert(resolve("/", path), Source::Snapshot(Arc::new(generate))); + } + /// Insert an endless byte device (or the empty null device). Writes are discarded. + pub fn insert_device(&mut self, path: &str, device: Device) { + self.files + .insert(resolve("/", path), Source::Device(device)); } fn directory(&self, path: &str) -> bool { path == "/" @@ -24,6 +30,25 @@ impl Generated { .any(|p| p.starts_with(&format!("{path}/"))) } } +enum Source { + Snapshot(Generator), + Device(Device), +} +impl Source { + fn file(&self) -> Result> { + match self { + Self::Snapshot(generate) => Ok(Arc::new(Snapshot(generate()?))), + Self::Device(device) => Ok(Arc::new(*device)), + } + } +} +/// Standard byte sources. All three discard writes and report zero size. +#[derive(Clone, Copy)] +pub enum Device { + Null, + Zero, + Random, +} struct Snapshot(Vec); fn readonly() -> Result { Err(error(E::ReadOnlyFilesystem)) @@ -32,7 +57,7 @@ fn readonly() -> Result { impl Layer for Generated { async fn stat(&self, path: &str, _follow: bool) -> Result { if let Some(f) = self.files.get(path) { - return Snapshot(f()?).stat().await; + return f.file()?.stat().await; } if !self.directory(path) { return Err(error(E::NotFound)); @@ -77,14 +102,17 @@ impl Layer for Generated { .collect()) } async fn open(&self, path: &str, o: Open) -> Result> { - if o.write || o.create || o.truncate { - return readonly(); - } if let Some(f) = self.files.get(path) { + if matches!(f, Source::Snapshot(_)) && (o.write || o.create || o.truncate) { + return readonly(); + } if o.directory { return Err(error(E::NotADirectory)); } - return Ok(Arc::new(Snapshot(f()?))); + return f.file(); + } + if o.write || o.create || o.truncate { + return readonly(); } if self.directory(path) { return Memory::default().open("/", o).await; @@ -137,3 +165,38 @@ impl File for Snapshot { readonly() } } +#[async_trait] +impl File for Device { + async fn stat(&self) -> Result { + Snapshot(Vec::new()).stat().await + } + async fn read(&self, _: u64, length: usize) -> Result> { + let mut bytes = vec![ + 0; + if matches!(self, Self::Null) { + 0 + } else { + length + } + ]; + if matches!(self, Self::Random) { + getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(e.to_string()))?; + } + Ok(bytes) + } + async fn write(&self, _: u64, bytes: Vec) -> Result { + Ok(bytes.len()) + } + async fn append(&self, bytes: Vec) -> Result { + Ok(bytes.len()) + } + async fn set_size(&self, _: u64) -> Result<()> { + Ok(()) + } + async fn sync(&self) -> Result<()> { + Ok(()) + } + async fn set_times(&self, _: Option, _: Option) -> Result<()> { + Ok(()) + } +} diff --git a/vfs/src/lib.rs b/vfs/src/lib.rs index 4ef66b0..76d38de 100644 --- a/vfs/src/lib.rs +++ b/vfs/src/lib.rs @@ -13,7 +13,7 @@ mod readonly; mod streams; pub use async_trait::async_trait; -pub use generated::Generated; +pub use generated::{Device, Generated}; pub use host::Host; pub use memory::Memory; pub use mounts::Mounts; diff --git a/vfs/tests/devices.rs b/vfs/tests/devices.rs new file mode 100644 index 0000000..31c7b84 --- /dev/null +++ b/vfs/tests/devices.rs @@ -0,0 +1,47 @@ +//! Standard generated devices discard writes and retain their read behavior across opens. +use sausage_vfs::{Device, Generated, Layer, Open}; + +#[tokio::test] +async fn generated_devices() { + let mut layer = Generated::default(); + for (name, device) in [ + ("null", Device::Null), + ("zero", Device::Zero), + ("urandom", Device::Random), + ] { + layer.insert_device(&format!("/{name}"), device); + } + assert_eq!(layer.list("/").await.unwrap().len(), 3); + for name in ["null", "zero", "urandom"] { + let file = layer + .open( + &format!("/{name}"), + Open { + write: true, + create: true, + truncate: true, + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(file.write(100, b"discard".to_vec()).await.unwrap(), 7); + assert_eq!(file.append(b"discard".to_vec()).await.unwrap(), 7); + file.set_size(100).await.unwrap(); + file.sync().await.unwrap(); + assert_eq!(file.stat().await.unwrap().size, 0); + let reopened = layer + .open(&format!("/{name}"), Open::default()) + .await + .unwrap(); + let bytes = reopened.read(100, 64).await.unwrap(); + match name { + "null" => assert!(bytes.is_empty()), + "zero" => assert_eq!(bytes, vec![0; 64]), + _ => { + assert_eq!(bytes.len(), 64); + assert_ne!(bytes, reopened.read(0, 64).await.unwrap()); + } + } + } +}