diff --git a/pkgs/id/src/cli.rs b/pkgs/id/src/cli.rs index 65aefd200..f8e103b9e 100644 --- a/pkgs/id/src/cli.rs +++ b/pkgs/id/src/cli.rs @@ -2035,6 +2035,43 @@ pub enum WorldCommand { #[arg(long)] no_relay: bool, }, + /// Fork a durable world on disk into a new world (F12.2). + /// + /// Copies the source world's journal and modules into the destination + /// directory under a new world id and records the fork provenance in + /// the new journal. Only the journal and the modules are copied: no + /// seed secret or other source file comes along (the F11.11 policy). + /// Local only; no host is contacted. + Fork { + /// Directory of the world to fork (the one holding `journal.jsonl`). + source: PathBuf, + /// Directory to create for the fork; it must be absent or empty. + dir: PathBuf, + /// The new world's id: `a-z`, `0-9`, `-` and `_`. Defaults to the + /// destination directory's name, which is the id a host serving + /// that directory will open it under. + #[arg(long)] + name: Option, + /// Mark the fork reusable as a template (recorded in its + /// provenance; see `id world provenance`). + #[arg(long)] + template: bool, + /// Print one JSON object instead of text lines. + #[arg(long)] + json: bool, + }, + /// Print a durable world's provenance from its journal: its id and, + /// for a fork, the world it was forked from, the module hash at the + /// fork point, and whether it was marked a template (F12.2). Local + /// only; no host is contacted. + Provenance { + /// Directory of the world to inspect (the one holding + /// `journal.jsonl`). + dir: PathBuf, + /// Print one JSON object instead of text lines. + #[arg(long)] + json: bool, + }, } /// Subcommands for `id doc`. A document is a `document.put` event over the content's hash. diff --git a/pkgs/id/src/commands/world.rs b/pkgs/id/src/commands/world.rs index 3425ed5d1..aa0193bb3 100644 --- a/pkgs/id/src/commands/world.rs +++ b/pkgs/id/src/commands/world.rs @@ -747,6 +747,155 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> { addrs, no_relay, } => ssh_tunnel(&node, listen.as_deref(), stdio, &addrs, no_relay).await, + WorldCommand::Fork { + source, + dir, + name, + template, + json, + } => fork_world_on_disk(&source, &dir, name.as_deref(), template, json), + WorldCommand::Provenance { dir, json } => print_world_provenance(&dir, json), + } +} + +/// Read a world directory's journal entries for inspection, without +/// opening the journal for append. A torn final fragment (a crash +/// mid-append, never acknowledged) is skipped, mirroring +/// `FileJournal::open`; any other unparseable line is corruption. +fn read_world_journal(dir: &std::path::Path) -> Result> { + let path = dir.join("journal.jsonl"); + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let mut lines: Vec<&[u8]> = bytes.split(|byte| *byte == b'\n').collect(); + if bytes.last() != Some(&b'\n') { + // The final chunk is an unterminated fragment (or the empty tail + // of an empty file): it was never durably acknowledged, so it is + // dropped even when it happens to parse — the same rule + // `FileJournal::open` and the store-level fork reader apply. + lines.pop(); + } + let mut entries = Vec::new(); + for line in lines { + if line.is_empty() { + continue; + } + let entry: crate::world::JournalEntry = serde_json::from_slice(line) + .with_context(|| format!("{} is corrupt", path.display()))?; + entries.push(entry); + } + Ok(entries) +} + +/// A world directory's provenance, read from its journal: the `Created` +/// header's id plus the `Forked` entry a fork appends, if any. +struct WorldProvenance { + world_id: String, + forked: Option<(String, String, bool, u64)>, +} + +fn world_provenance(dir: &std::path::Path) -> Result { + let entries = read_world_journal(dir)?; + let Some(crate::world::JournalEntry::Created { world_id, .. }) = entries.first() else { + bail!( + "{} has no Created header", + dir.join("journal.jsonl").display() + ); + }; + let world_id = world_id.clone(); + let forked = entries.iter().rev().find_map(|entry| match entry { + crate::world::JournalEntry::Forked { + from_world_id, + from_module_hash, + template, + at_sequence, + } => Some(( + from_world_id.clone(), + from_module_hash.clone(), + *template, + *at_sequence, + )), + _ => None, + }); + Ok(WorldProvenance { world_id, forked }) +} + +/// `id world provenance`: print a world directory's provenance, so a +/// template fork is identifiable without opening the world. +fn print_world_provenance(dir: &std::path::Path, json: bool) -> Result<()> { + let provenance = world_provenance(dir)?; + if json { + let (from_world_id, from_module_hash, template, at_sequence) = match &provenance.forked { + Some((from, hash, template, at)) => { + (Some(from.clone()), Some(hash.clone()), *template, Some(*at)) + } + None => (None, None, false, None), + }; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "world_id": provenance.world_id, + "dir": dir.display().to_string(), + "forked_from": from_world_id, + "from_module_hash": from_module_hash, + "template": template, + "at_sequence": at_sequence, + }))? + ); + } else { + println!("world: {}", provenance.world_id); + match &provenance.forked { + Some((from, hash, template, at)) => { + println!("forked_from: {from}"); + println!("from_module_hash: {hash}"); + println!("template: {template}"); + println!("at_sequence: {at}"); + } + None => println!("forked_from: none"), + } + } + Ok(()) +} + +/// `id world fork`: wire the store-level `fork_world` (F12.2) into the +/// CLI. The fork's provenance is read back from the new journal and +/// printed, so the caller sees exactly what was recorded. +fn fork_world_on_disk( + source: &std::path::Path, + dir: &std::path::Path, + name: Option<&str>, + template: bool, + json: bool, +) -> Result<()> { + let to_world_id = match name { + Some(name) => name.to_owned(), + None => dir + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .context("the destination directory has no name; pass --name")?, + }; + crate::world_hub::validate_world_name(&to_world_id)?; + if dir.exists() { + anyhow::ensure!( + std::fs::read_dir(dir)?.next().is_none(), + "{} is not empty", + dir.display() + ); + } + let from_world_id = world_provenance(source)?.world_id; + crate::world_store::fork_world( + source, + dir, + &crate::world_store::ForkSpec { + from_world_id, + to_world_id, + template, + }, + )?; + if json { + print_world_provenance(dir, true) + } else { + println!("forked {}", dir.display()); + print_world_provenance(dir, false) } } diff --git a/pkgs/id/tests/world_fork_cli.rs b/pkgs/id/tests/world_fork_cli.rs new file mode 100644 index 000000000..ac2e07dea --- /dev/null +++ b/pkgs/id/tests/world_fork_cli.rs @@ -0,0 +1,238 @@ +//! F12.2 CLI surface: `id world fork` and `id world provenance`. +//! +//! The store-level fork is covered by `world_fork.rs`; this suite runs the +//! actual binary: a fork creates a world whose journal restores under the +//! new id and reports its provenance, `--template` is recorded and visible +//! through `id world provenance`, and the forked directory contains only +//! the journal and the modules — the F11.11 seed-inheritance pin (journal +//! state only; no seed secret or other source file is copied). +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use id::world::{JournalEntry, WorldCore, WorldEvent, WorldEventKind, WorldJournal, WorldLimits}; +use id::world_store::{FileJournal, ModuleDir}; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_id")) +} + +fn run(args: &[&str]) -> std::process::Output { + Command::new(binary()).args(args).output().unwrap() +} + +fn run_ok(args: &[&str]) -> String { + let output = run(args); + assert!( + output.status.success(), + "id {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +/// A source world on disk: an installed program, one module file, and a +/// `seed.secret` file standing in for every non-journal file a world +/// directory might hold — none of them may reach the fork. +fn make_source(root: &Path) -> (PathBuf, String) { + let src = root.join("src-world"); + std::fs::create_dir_all(&src).unwrap(); + let modules = ModuleDir::new(src.join("modules")); + let hash = modules.save(b"wasm-bytes").unwrap(); + let (mut journal, _) = FileJournal::open(&src.join("journal.jsonl"), "src-world").unwrap(); + journal + .append(&JournalEntry::Committed { + event: WorldEvent { + sequence: 1, + participant_id: 0, + kind: WorldEventKind::ProgramInstalled { + module_hash: hash.clone(), + seed: 7, + provenance: None, + }, + }, + }) + .unwrap(); + drop(journal); + std::fs::write(src.join("seed.secret"), b"never copy me").unwrap(); + (src, hash) +} + +fn top_level_names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + names.sort(); + names +} + +#[test] +fn cli_fork_creates_a_world_that_restores_and_reports_provenance() { + let tmp = tempfile::tempdir().unwrap(); + let (src, hash) = make_source(tmp.path()); + let dst = tmp.path().join("dst-world"); + + let stdout = run_ok(&[ + "world", + "fork", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ]); + assert!(stdout.contains("forked_from: src-world"), "{stdout}"); + assert!(stdout.contains("template: false"), "{stdout}"); + + // The forked journal opens and the world restores under its new id + // (the directory name is the default world id). + let (_, entries) = FileJournal::open(&dst.join("journal.jsonl"), "dst-world").unwrap(); + assert!(matches!( + &entries[0], + JournalEntry::Created { world_id, .. } if world_id == "dst-world" + )); + let restored = WorldCore::restore("dst-world", WorldLimits::default(), entries).unwrap(); + assert_eq!(restored.core.world_id(), "dst-world"); + let program = restored.program.expect("the parent's install restores"); + assert_eq!(program.module_hash, hash); + assert_eq!(program.seed, 7); + + // The module the parent's install names was copied under its hash. + assert!(dst.join("modules").join(format!("{hash}.wasm")).is_file()); + + // `id world provenance` identifies the fork and its origin. + let stdout = run_ok(&["world", "provenance", dst.to_str().unwrap(), "--json"]); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(value["world_id"], "dst-world"); + assert_eq!(value["forked_from"], "src-world"); + assert_eq!(value["from_module_hash"], hash); + assert_eq!(value["template"], false); + assert_eq!(value["at_sequence"], 1); +} + +#[test] +fn cli_fork_copies_only_the_journal_and_modules() { + let tmp = tempfile::tempdir().unwrap(); + let (src, _hash) = make_source(tmp.path()); + let dst = tmp.path().join("dst-world"); + + run_ok(&[ + "world", + "fork", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ]); + + // F11.11 pin: journal state only. The source's seed.secret — and any + // other file — is not inherited by the fork. + assert_eq!(top_level_names(&dst), ["journal.jsonl", "modules"]); +} + +#[test] +fn cli_fork_template_flag_is_recorded_and_identifiable() { + let tmp = tempfile::tempdir().unwrap(); + let (src, _hash) = make_source(tmp.path()); + let dst = tmp.path().join("template-world"); + + let stdout = run_ok(&[ + "world", + "fork", + src.to_str().unwrap(), + dst.to_str().unwrap(), + "--template", + "--json", + ]); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(value["world_id"], "template-world"); + assert_eq!(value["template"], true); + + // And the template marking stays identifiable after the fact. + let stdout = run_ok(&["world", "provenance", dst.to_str().unwrap()]); + assert!(stdout.contains("template: true"), "{stdout}"); + + // The source itself is not a fork: provenance says so. + let stdout = run_ok(&["world", "provenance", src.to_str().unwrap(), "--json"]); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(value["world_id"], "src-world"); + assert!(value["forked_from"].is_null()); + assert_eq!(value["template"], false); +} + +#[test] +fn cli_provenance_reports_the_most_recent_fork_in_a_chain() { + let tmp = tempfile::tempdir().unwrap(); + let (src, _hash) = make_source(tmp.path()); + let middle = tmp.path().join("middle-world"); + let leaf = tmp.path().join("leaf-world"); + + run_ok(&[ + "world", + "fork", + src.to_str().unwrap(), + middle.to_str().unwrap(), + ]); + // Forking the fork appends a second Forked entry; the leaf's + // provenance is its own record (origin = middle, template = true), + // not the inherited one. + run_ok(&[ + "world", + "fork", + middle.to_str().unwrap(), + leaf.to_str().unwrap(), + "--template", + ]); + + let stdout = run_ok(&["world", "provenance", leaf.to_str().unwrap(), "--json"]); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(value["world_id"], "leaf-world"); + assert_eq!(value["forked_from"], "middle-world"); + assert_eq!(value["template"], true); +} + +#[test] +fn cli_provenance_discards_an_unterminated_final_entry() { + use std::io::Write as _; + let tmp = tempfile::tempdir().unwrap(); + let (src, _hash) = make_source(tmp.path()); + + // A crash left a complete-JSON Forked entry with no terminating + // newline: never acknowledged, so provenance must not report it. + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(src.join("journal.jsonl")) + .unwrap(); + file.write_all( + br#"{"entry":"forked","from_world_id":"ghost","from_module_hash":"x","template":true,"at_sequence":99}"#, + ) + .unwrap(); + drop(file); + + let stdout = run_ok(&["world", "provenance", src.to_str().unwrap(), "--json"]); + let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!(value["forked_from"].is_null(), "{value}"); + assert_eq!(value["template"], false); +} + +#[test] +fn cli_fork_refuses_a_non_empty_destination_and_a_bad_name() { + let tmp = tempfile::tempdir().unwrap(); + let (src, _hash) = make_source(tmp.path()); + let dst = tmp.path().join("dst-world"); + std::fs::create_dir_all(&dst).unwrap(); + std::fs::write(dst.join("occupied"), b"x").unwrap(); + + let output = run(&[ + "world", + "fork", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ]); + assert!(!output.status.success()); + + let output = run(&[ + "world", + "fork", + src.to_str().unwrap(), + tmp.path().join("Other").to_str().unwrap(), + ]); + assert!(!output.status.success()); +}