276 lines
11 KiB
Rust
276 lines
11 KiB
Rust
//! A world directory and the one virtual root that host code and guests share.
|
|
//!
|
|
//! 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 anyhow::Context;
|
|
use sausage_vfs::{Device, Generated, Host, Layer, Memory, Mounts, Process};
|
|
use std::{
|
|
os::unix::fs::PermissionsExt,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
|
|
/// An open world. `root` is the filesystem every guest sees as `/`; host code (the harness's file
|
|
/// tools) reads and writes through the same layer, so both always see the same files.
|
|
pub struct World {
|
|
pub root: Arc<dyn Layer>,
|
|
/// The host state folder, `<dir>/host`.
|
|
pub host_dir: PathBuf,
|
|
/// Live process metadata shared with the process table.
|
|
pub processes: crate::process::Processes,
|
|
}
|
|
|
|
fn copy_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
|
|
std::fs::create_dir_all(destination)?;
|
|
let mut permissions = std::fs::metadata(destination)?.permissions();
|
|
permissions.set_mode(permissions.mode() | 0o200);
|
|
std::fs::set_permissions(destination, permissions)?;
|
|
for entry in std::fs::read_dir(source)? {
|
|
let entry = entry?;
|
|
let target = destination.join(entry.file_name());
|
|
if entry.file_type()?.is_symlink() {
|
|
std::os::unix::fs::symlink(std::fs::read_link(entry.path())?, &target)?;
|
|
} else if entry.file_type()?.is_dir() {
|
|
copy_tree(&entry.path(), &target)?;
|
|
} else {
|
|
std::fs::copy(entry.path(), &target)?;
|
|
let mut permissions = std::fs::metadata(&target)?.permissions();
|
|
permissions.set_mode(permissions.mode() | 0o200);
|
|
std::fs::set_permissions(&target, permissions)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
impl World {
|
|
/// Initialize a fresh world and commit its plain bundled source on main.
|
|
pub async fn init(dir: &Path, bundle: &Path) -> anyhow::Result<Self> {
|
|
Self::init_with_progress(dir, bundle, |_| {}).await
|
|
}
|
|
|
|
/// Initialize a world and report each step without changing normal startup output.
|
|
pub async fn init_with_progress(
|
|
dir: &Path,
|
|
bundle: &Path,
|
|
mut progress: impl FnMut(&str) + Send,
|
|
) -> anyhow::Result<Self> {
|
|
anyhow::ensure!(
|
|
!dir.join("world").exists(),
|
|
"Create world folder failed: World already exists: {}",
|
|
dir.display()
|
|
);
|
|
let world = Self::open_with_progress(dir, Some(bundle), &mut progress)
|
|
.context("Create world folder and seed files failed")?;
|
|
progress("Create the first commit in /src/sausage");
|
|
let exec = crate::process::exec(Self {
|
|
root: world.root.clone(),
|
|
host_dir: world.host_dir.clone(),
|
|
processes: world.processes.clone(),
|
|
})
|
|
.context("Create source repository failed")?;
|
|
for args in [
|
|
vec!["init", "-b", "main"],
|
|
vec!["add", "."],
|
|
vec!["commit", "--no-gpg-sign", "-m", "Initial world source"],
|
|
] {
|
|
let argv = [
|
|
vec!["/bin/git.wasm".to_owned()],
|
|
args.into_iter().map(str::to_owned).collect(),
|
|
]
|
|
.concat();
|
|
let mut output = Vec::new();
|
|
let code = exec
|
|
.exec(
|
|
crate::exec::Command::Argv(argv),
|
|
crate::exec::Options {
|
|
cwd: "/src/sausage".into(),
|
|
..Default::default()
|
|
},
|
|
&mut |_, bytes| output.extend_from_slice(bytes),
|
|
)
|
|
.await
|
|
.context("Create source repository failed")?;
|
|
anyhow::ensure!(
|
|
code == 0,
|
|
"Cannot initialize source repository: {}",
|
|
String::from_utf8_lossy(&output)
|
|
);
|
|
}
|
|
Ok(world)
|
|
}
|
|
|
|
/// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts.
|
|
pub fn open(dir: &Path) -> anyhow::Result<Self> {
|
|
Self::open_with_userland(dir, None)
|
|
}
|
|
|
|
/// Copy an optional bundle into a new world. Existing worlds retain their programs.
|
|
/// Ensure `/tmp`, `/run`, and `/var/tmp` exist on every startup.
|
|
/// 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.
|
|
/// Missing `/etc/gitconfig` defaults to Sausage <sausage@localhost>. Local repository config overrides it.
|
|
pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result<Self> {
|
|
Self::open_with_progress(dir, userland, &mut |_| {})
|
|
}
|
|
|
|
fn open_with_progress(
|
|
dir: &Path,
|
|
userland: Option<&Path>,
|
|
progress: &mut dyn FnMut(&str),
|
|
) -> anyhow::Result<Self> {
|
|
progress(&format!("Create world folder: {}", dir.display()));
|
|
let root_dir = dir.join("world");
|
|
let host_dir = dir.join("host");
|
|
let fresh = !root_dir.exists();
|
|
std::fs::create_dir_all(root_dir.join("usr/local/bin"))
|
|
.context("Create world folder failed")?;
|
|
for path in ["tmp", "run", "var/tmp"] {
|
|
std::fs::create_dir_all(root_dir.join(path)).context("Create world folder failed")?;
|
|
}
|
|
if fresh && let Some(bundle) = userland {
|
|
let tools = std::fs::read_dir(bundle.join("bin"))
|
|
.context("Copy tools failed")?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.context("Copy tools failed")?
|
|
.iter()
|
|
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "wasm"))
|
|
.count();
|
|
progress(&format!("Copy tools: {tools}"));
|
|
copy_tree(&bundle.join("bin"), &root_dir.join("bin")).context("Copy tools failed")?;
|
|
progress("Create /src/sausage");
|
|
copy_tree(&bundle.join("src"), &root_dir.join("src"))
|
|
.context("Create /src/sausage failed")?;
|
|
progress("Seed /etc and /srv/www");
|
|
if bundle.join("srv").is_dir() {
|
|
copy_tree(&bundle.join("srv"), &root_dir.join("srv"))
|
|
.context("Seed /srv/www failed")?;
|
|
}
|
|
if bundle.join("etc").is_dir() {
|
|
progress(&format!(
|
|
"Write config: {}",
|
|
root_dir.join("etc/sausage/config.yaml").display()
|
|
));
|
|
copy_tree(&bundle.join("etc"), &root_dir.join("etc"))
|
|
.context("Seed /etc and write config failed")?;
|
|
}
|
|
// 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)?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let gitconfig = root_dir.join("etc/gitconfig");
|
|
if !gitconfig.try_exists()? {
|
|
std::fs::create_dir_all(root_dir.join("etc"))?;
|
|
std::fs::write(
|
|
gitconfig,
|
|
"[user]\n\tname = Sausage\n\temail = sausage@localhost\n",
|
|
)?;
|
|
}
|
|
std::fs::create_dir_all(&host_dir)?;
|
|
let mut mounts = Mounts::default();
|
|
mounts.mount(
|
|
"/",
|
|
Arc::new(Host::new(&root_dir, Process::default())?),
|
|
false,
|
|
);
|
|
let processes: crate::process::Processes =
|
|
Arc::new(std::sync::Mutex::new(Default::default()));
|
|
let snapshot = processes.clone();
|
|
let mut proc = Generated::default();
|
|
let extensions = crate::harness::loader::status(&host_dir);
|
|
proc.insert("/extensions", move || {
|
|
Ok(extensions.lock().unwrap().as_bytes().to_vec())
|
|
});
|
|
proc.insert("/processes", move || {
|
|
let mut text = String::from("PID\tPPID\tSTATE\tCWD\tCONVERSATION\tARGV\n");
|
|
for (pid, info) in snapshot.lock().unwrap().iter() {
|
|
text.push_str(&format!(
|
|
"{pid}\t{}\t{}\t{}\t{}\t{}\n",
|
|
info.parent,
|
|
info.state,
|
|
info.cwd,
|
|
info.conversation.as_deref().unwrap_or("-"),
|
|
info.argv.join(" ")
|
|
));
|
|
}
|
|
Ok(text.into_bytes())
|
|
});
|
|
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(
|
|
std::process::id(),
|
|
"/host".into(),
|
|
Vec::new(),
|
|
)?);
|
|
Ok(World {
|
|
root,
|
|
host_dir,
|
|
processes,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[test]
|
|
fn readonly_bundle_copies_are_writable() -> std::io::Result<()> {
|
|
use std::fs;
|
|
let bundle = tempfile::tempdir()?;
|
|
let world = tempfile::tempdir()?;
|
|
for (name, mode) in [("bin/tool.wasm", 0o555), ("src/lib.rs", 0o444)] {
|
|
let path = bundle.path().join(name);
|
|
fs::create_dir_all(path.parent().unwrap())?;
|
|
fs::write(&path, b"original")?;
|
|
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
|
|
}
|
|
for path in [
|
|
bundle.path().join("bin"),
|
|
bundle.path().join("src"),
|
|
bundle.path().to_owned(),
|
|
world.path().to_owned(),
|
|
] {
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o555))?;
|
|
}
|
|
let result = copy_tree(bundle.path(), world.path());
|
|
// Restore source directories so the temporary directory can remove its files.
|
|
for path in [
|
|
bundle.path(),
|
|
&bundle.path().join("bin"),
|
|
&bundle.path().join("src"),
|
|
] {
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
|
|
}
|
|
result?;
|
|
assert_eq!(
|
|
fs::metadata(world.path())?.permissions().mode() & 0o777,
|
|
0o755
|
|
);
|
|
for (name, mode) in [("bin/tool.wasm", 0o555), ("src/lib.rs", 0o444)] {
|
|
let source = bundle.path().join(name);
|
|
let target = world.path().join(name);
|
|
assert_eq!(fs::metadata(&source)?.permissions().mode() & 0o777, mode);
|
|
assert_eq!(
|
|
fs::metadata(&target)?.permissions().mode() & 0o777,
|
|
mode | 0o200
|
|
);
|
|
fs::write(&target, b"changed")?;
|
|
assert_eq!(fs::read(source)?, b"original");
|
|
}
|
|
fs::write(world.path().join("bin/added.wasm"), b"new")?;
|
|
fs::write(world.path().join("src/added.rs"), b"new")?;
|
|
Ok(())
|
|
}
|