Add world CLI compatibility for Git, xargs, devices and tool aliases
This commit is contained in:
+18
-1
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user