Add world CLI compatibility for Git, xargs, devices and tool aliases

This commit is contained in:
2026-11-08 00:58:30 +00:00
parent 3134be3ebf
commit ffee43b33c
12 changed files with 475 additions and 32 deletions
+18 -1
View File
@@ -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/<name>` 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<Self> {
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(
+35 -4
View File
@@ -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(
+96 -13
View File
@@ -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<i32, Box<dyn std::error::Error>> {
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::<Result<Vec<_>, _>>()?
} 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::<Vec<_>>()
} 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<String, Box<dyn std::error::Error>> {
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
}
});
}
+31
View File
@@ -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)"
}
}
+2 -1
View File
@@ -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`.
+107
View File
@@ -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<i32> {
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::<usize>()
.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::<String>();
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::*;
+8 -2
View File
@@ -207,7 +207,7 @@ enum Command {
diff_merges: Option<String>,
#[arg(long)]
oneline: bool,
#[arg(short = 'n')]
#[arg(short = 'n', long = "max-count")]
limit: Option<usize>,
#[arg(long)]
format: Option<String>,
@@ -222,6 +222,8 @@ enum Command {
paths: Vec<String>,
},
Diff {
#[arg(long)]
check: bool,
#[arg(long)]
numstat: bool,
#[arg(long)]
@@ -892,6 +894,7 @@ pub fn run(cli: Cli) -> Result<i32> {
}
}
Command::Diff {
check,
numstat,
no_renames,
diff_filter,
@@ -919,13 +922,16 @@ pub fn run(cli: Cli) -> Result<i32> {
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 {
+29 -2
View File
@@ -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() {
+30
View File
@@ -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} }
+71 -8
View File
@@ -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<dyn Fn() -> Result<Vec<u8>> + Send + Sync>;
@@ -6,7 +6,7 @@ type Generator = Arc<dyn Fn() -> Result<Vec<u8>> + 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<String, Generator>,
files: BTreeMap<String, Source>,
}
impl Generated {
pub fn insert(
@@ -14,7 +14,13 @@ impl Generated {
path: &str,
generate: impl Fn() -> Result<Vec<u8>> + 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<Arc<dyn File>> {
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<u8>);
fn readonly<T>() -> Result<T> {
Err(error(E::ReadOnlyFilesystem))
@@ -32,7 +57,7 @@ fn readonly<T>() -> Result<T> {
impl Layer for Generated {
async fn stat(&self, path: &str, _follow: bool) -> Result<Stat> {
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<Arc<dyn File>> {
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<Stat> {
Snapshot(Vec::new()).stat().await
}
async fn read(&self, _: u64, length: usize) -> Result<Vec<u8>> {
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<u8>) -> Result<usize> {
Ok(bytes.len())
}
async fn append(&self, bytes: Vec<u8>) -> Result<usize> {
Ok(bytes.len())
}
async fn set_size(&self, _: u64) -> Result<()> {
Ok(())
}
async fn sync(&self) -> Result<()> {
Ok(())
}
async fn set_times(&self, _: Option<SystemTime>, _: Option<SystemTime>) -> Result<()> {
Ok(())
}
}
+1 -1
View File
@@ -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;
+47
View File
@@ -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());
}
}
}
}