//! 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, /// The host state folder, `/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::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 { 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::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/` symlink. /// `/dev` exposes generated null, zero, and random byte devices in every world. /// Missing `/etc/gitconfig` defaults to Sausage . Local repository config overrides it. pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result { Self::open_with_progress(dir, userland, &mut |_| {}) } fn open_with_progress( dir: &Path, userland: Option<&Path>, progress: &mut dyn FnMut(&str), ) -> anyhow::Result { 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::, _>>() .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(()) }