364 lines
13 KiB
Rust
364 lines
13 KiB
Rust
//! P1 integration: bundle contents, source commits, extension loading, and real shell calls.
|
|
use sausage::{harness::run_module, world::World};
|
|
use sausage_vfs::Open;
|
|
use std::path::PathBuf;
|
|
|
|
fn bundle() -> anyhow::Result<PathBuf> {
|
|
if let Some(bundle) = std::env::var_os("SAUSAGE_USERLAND") {
|
|
return Ok(PathBuf::from(bundle));
|
|
}
|
|
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
|
let bundle = crate::common::target_dir().join("bundle");
|
|
let _guard = crate::common::BUILD.lock().unwrap();
|
|
let key = crate::common::bundle_key(&repo)?;
|
|
let stamp = bundle.join(".source-key");
|
|
if !bundle.join("bin/sh.wasm").exists()
|
|
|| !bundle.join("etc/sausage/config.yaml").exists()
|
|
|| std::fs::read_to_string(&stamp).ok().as_deref() != Some(key.as_str())
|
|
{
|
|
if bundle.exists() {
|
|
std::fs::remove_dir_all(&bundle)?;
|
|
}
|
|
assert!(
|
|
std::process::Command::new("nu")
|
|
.arg(repo.join("userland/bundle.nu"))
|
|
.arg(&bundle)
|
|
.status()?
|
|
.success()
|
|
);
|
|
std::fs::write(stamp, key)?;
|
|
}
|
|
Ok(bundle)
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
|
|
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
|
|
let bundle = bundle()?;
|
|
let dir = tempfile::tempdir()?;
|
|
let world = World::init(dir.path(), &bundle).await?;
|
|
assert!(world.root.stat("/bin/sh.wasm", true).await?.size > 0);
|
|
let snapshot = dir.path().join("world/src/sausage");
|
|
for path in [
|
|
"config/config.yaml",
|
|
"skills/README.md",
|
|
"extensions/README.md",
|
|
] {
|
|
assert!(snapshot.join(path).is_file(), "missing bundled {path}");
|
|
}
|
|
let git = |path: &std::path::Path, args: &[&str]| -> anyhow::Result<String> {
|
|
let output = std::process::Command::new("git")
|
|
.arg("-C")
|
|
.arg(path)
|
|
.args(args)
|
|
.output()?;
|
|
anyhow::ensure!(
|
|
output.status.success(),
|
|
"git {args:?}: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
|
|
};
|
|
let message = git(&snapshot, &["log", "-1", "--format=%s"])?;
|
|
assert_eq!(message, "Initial world source");
|
|
for path in ["config", "skills", "extensions"] {
|
|
let difference = std::process::Command::new("git")
|
|
.args(["diff", "--no-index", "--exit-code", "--"])
|
|
.arg(repo.join(path))
|
|
.arg(snapshot.join(path))
|
|
.output()?;
|
|
assert!(
|
|
difference.status.success(),
|
|
"bundled {path} must match the source files: {}{}",
|
|
String::from_utf8_lossy(&difference.stdout),
|
|
String::from_utf8_lossy(&difference.stderr),
|
|
);
|
|
}
|
|
use std::os::unix::fs::MetadataExt;
|
|
for entry in std::fs::read_dir(dir.path().join("world/bin"))? {
|
|
let entry = entry?;
|
|
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
|
|
.root
|
|
.open(
|
|
"/bin/forbidden",
|
|
Open {
|
|
write: true,
|
|
create: true,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
assert!(writable.is_ok());
|
|
let exec = sausage::process::exec(World {
|
|
root: world.root.clone(),
|
|
host_dir: world.host_dir.clone(),
|
|
processes: world.processes.clone(),
|
|
})?;
|
|
use sausage::exec::{Command, Options, Stream};
|
|
for (script, expected) in [
|
|
("command -v ls", "/bin/ls\n"),
|
|
("ls", "bin"),
|
|
("git --version", "git version"),
|
|
("printf 'a\\n' | xargs echo", "a\n"),
|
|
("env FOO=1 printenv FOO", "1\n"),
|
|
] {
|
|
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!(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}");
|
|
}
|
|
for utility in [
|
|
"tail -n 5",
|
|
"head -n 2",
|
|
"wc -l",
|
|
"sort",
|
|
"uniq",
|
|
"cut -c 1",
|
|
] {
|
|
for script in [
|
|
format!("printf 'g\\nb\\nb\\na\\nf\\ne\\nd\\n' | {utility}"),
|
|
format!("printf '' | {utility}"),
|
|
format!("printf 'g\\nb\\n' | {utility} | head -n 0"),
|
|
] {
|
|
let native = std::process::Command::new("bash")
|
|
.args(["-c", &script])
|
|
.output()?;
|
|
assert!(native.status.success(), "{script}: {:?}", native.stderr);
|
|
let mut out = Vec::new();
|
|
let mut err = Vec::new();
|
|
let code = exec
|
|
.exec(
|
|
Command::Shell(script.clone()),
|
|
Options {
|
|
timeout: Some(std::time::Duration::from_secs(15)),
|
|
..Options::default()
|
|
},
|
|
&mut |stream, bytes| match stream {
|
|
Stream::Stdout => out.extend_from_slice(bytes),
|
|
Stream::Stderr => err.extend_from_slice(bytes),
|
|
},
|
|
)
|
|
.await?;
|
|
assert_eq!(code, 0, "{script}: {}", String::from_utf8_lossy(&err));
|
|
assert_eq!(out, native.stdout, "{script}");
|
|
}
|
|
}
|
|
let mut history = Vec::new();
|
|
assert_eq!(
|
|
exec.exec(
|
|
Command::Shell("cd /src/sausage && git log --oneline".into()),
|
|
Options::default(),
|
|
&mut |stream, bytes| {
|
|
if matches!(stream, Stream::Stdout) {
|
|
history.extend_from_slice(bytes);
|
|
}
|
|
},
|
|
)
|
|
.await?,
|
|
0,
|
|
);
|
|
let history = String::from_utf8(history)?;
|
|
assert_eq!(history.lines().count(), 1);
|
|
assert!(history.contains("Initial world source"));
|
|
let mut status = Vec::new();
|
|
assert_eq!(
|
|
exec.exec(
|
|
Command::Shell("cd /src/sausage && git status --short".into()),
|
|
Options::default(),
|
|
&mut |stream, bytes| {
|
|
if matches!(stream, Stream::Stdout) {
|
|
status.extend_from_slice(bytes);
|
|
}
|
|
},
|
|
)
|
|
.await?,
|
|
0,
|
|
);
|
|
assert!(status.is_empty(), "{}", String::from_utf8_lossy(&status));
|
|
let source = format!(
|
|
"globalThis.testP1 = true;\n{}",
|
|
include_str!("../../js/test-bundle.js")
|
|
);
|
|
run_module(world, exec.clone(), &source).await?;
|
|
assert_eq!(
|
|
std::fs::read_to_string(dir.path().join("world/test.txt"))?,
|
|
"after\n"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(dir.path().join("world/parallel/a"))?,
|
|
"a"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(dir.path().join("world/parallel/b"))?,
|
|
"b"
|
|
);
|
|
let spills = std::fs::read_dir(dir.path().join("world/tmp"))?.collect::<Result<Vec<_>, _>>()?;
|
|
assert_eq!(spills.len(), 1);
|
|
let actual = std::fs::read_to_string(spills[0].path())?;
|
|
let expected: String = (1..=10000).map(|n| format!("{n}\n")).collect();
|
|
assert_eq!(actual, expected);
|
|
let source_cat = std::fs::read(bundle.join("bin/cat.wasm"))?;
|
|
let head = std::fs::read(dir.path().join("world/bin/head.wasm"))?;
|
|
let mut out = Vec::new();
|
|
assert_eq!(
|
|
exec.exec(
|
|
Command::Shell(
|
|
"cp /bin/true.wasm /bin/cat.wasm; cat /test.txt; head -n 1 /test.txt".into()
|
|
),
|
|
Options::default(),
|
|
&mut |stream, bytes| {
|
|
if matches!(stream, Stream::Stdout) {
|
|
out.extend_from_slice(bytes);
|
|
}
|
|
}
|
|
)
|
|
.await?,
|
|
0
|
|
);
|
|
assert_eq!(out, b"after\n");
|
|
assert_eq!(std::fs::read(bundle.join("bin/cat.wasm"))?, source_cat);
|
|
assert_eq!(std::fs::read(dir.path().join("world/bin/head.wasm"))?, head);
|
|
assert_eq!(
|
|
exec.exec(
|
|
Command::Shell("cp /bin/true.wasm /usr/local/bin/head.wasm; head /test.txt".into()),
|
|
Options::default(),
|
|
&mut |_, bytes| assert!(bytes.is_empty())
|
|
)
|
|
.await?,
|
|
0
|
|
);
|
|
let changed = std::fs::read(dir.path().join("world/bin/cat.wasm"))?;
|
|
let reopened = World::open_with_userland(
|
|
dir.path(),
|
|
Some(std::path::Path::new("/nonexistent-bundle")),
|
|
)?;
|
|
assert_eq!(
|
|
reopened
|
|
.root
|
|
.open("/bin/cat.wasm", Open::default())
|
|
.await?
|
|
.read(0, changed.len())
|
|
.await?,
|
|
changed
|
|
);
|
|
// An unbundled world remains usable by process tests and file-only callers.
|
|
let unbundled = tempfile::tempdir()?;
|
|
let world = World::open(unbundled.path())?;
|
|
assert!(world.root.stat("/bin/sh", true).await.is_err());
|
|
Ok(())
|
|
}
|
|
|
|
/// Preserve command timings in captured test output when the loaded-machine regression fails.
|
|
struct LoaderTiming(std::sync::Arc<dyn sausage::exec::Exec>);
|
|
#[async_trait::async_trait]
|
|
impl sausage::exec::Exec for LoaderTiming {
|
|
async fn exec(
|
|
&self,
|
|
command: sausage::exec::Command,
|
|
options: sausage::exec::Options,
|
|
output: &mut (dyn for<'a> FnMut(sausage::exec::Stream, &'a [u8]) + Send),
|
|
) -> anyhow::Result<i32> {
|
|
let label = format!("{command:?}");
|
|
let budget = options.timeout;
|
|
let started = std::time::Instant::now();
|
|
let result = self.0.exec(command, options, output).await;
|
|
eprintln!(
|
|
"Loader {label}: deadline {budget:?}, elapsed {:?}, result {result:?}",
|
|
started.elapsed()
|
|
);
|
|
result
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn bundled_extensions_load() -> anyhow::Result<()> {
|
|
let bundle = bundle()?;
|
|
let dir = tempfile::tempdir()?;
|
|
let world = World::init(dir.path(), &bundle).await?;
|
|
let root = world.root.clone();
|
|
let exec = sausage::process::exec(World {
|
|
root: world.root.clone(),
|
|
host_dir: world.host_dir.clone(),
|
|
processes: world.processes.clone(),
|
|
})?;
|
|
// Use the compiled production loader without the console entrypoint or provider bootstrap.
|
|
let (library, _) = include_str!("../../js/bundle.js")
|
|
.split_once("// main.ts")
|
|
.expect("console bundle entrypoint");
|
|
let source = format!(
|
|
r#"{library}
|
|
const bundleModels = createModels();
|
|
const bundleRegistry = createRegistry();
|
|
bundleRegistry.install(CodingTools2);
|
|
bundleRegistry.install(CredentialRequests);
|
|
const bundleStorage = await openStorage(globalThis.storagePath);
|
|
const bundleHarness = await Harness.open(bundleStorage, {{
|
|
models: bundleModels, registry: bundleRegistry,
|
|
settings: {{ get extensions() {{ return bundleRegistry.snapshot().installed(); }} }}
|
|
}}, BACKGROUND_CONTEXT);
|
|
await bundleHarness.root(BACKGROUND_CONTEXT);
|
|
const bundleLoader = await loadExtensions(bundleHarness, bundleRegistry, bundleStorage,
|
|
bundleModels, BACKGROUND_CONTEXT, [CodingTools2, CredentialRequests]);
|
|
if (bundleLoader.state().error) throw new Error(bundleLoader.state().error);
|
|
const snapshotEntries=await fs2.list(`/run/sausage/extensions/${{bundleLoader.state().good}}`);
|
|
if(JSON.stringify(snapshotEntries.map(entry=>entry.name)) !== '["extensions"]') throw new Error(JSON.stringify(snapshotEntries));
|
|
await bundleLoader.close();
|
|
await bundleHarness.close(BACKGROUND_CONTEXT);
|
|
"#
|
|
);
|
|
run_module(world, std::sync::Arc::new(LoaderTiming(exec)), &source).await?;
|
|
let status = root.open("/proc/extensions", Open::default()).await?;
|
|
let status: serde_json::Value = serde_json::from_slice(&status.read(0, 65536).await?)?;
|
|
let commit =
|
|
std::fs::read_to_string(dir.path().join("world/src/sausage/.git/refs/heads/main"))?;
|
|
assert_eq!(status["good"].as_str(), Some(commit.trim()), "{status}");
|
|
assert!(status["error"].is_null(), "{status}");
|
|
Ok(())
|
|
}
|