sprawling-runtime 0.0.11

One run of a sprawling resident, from dispatch to freeze: turns, tools, replay and the sandbox that executes code.
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

use super::*;
use crate::PolicyCell;
use kernel::{Address, Mode, RunPolicy};

fn tool(root: &Path) -> EditTool {
    let work = Address::parse("work").unwrap();
    let domain = kernel::WriteDomain::new(vec![work.clone()]).unwrap();
    std::fs::create_dir_all(root.join("work")).unwrap();
    let policy = PolicyCell::new(RunPolicy::of(Mode::Work)).reader();
    EditTool::new(root, work, domain, policy).unwrap()
}

fn call(path: &str, base: &str, old: &str, new: &str) -> ToolCall {
    let mut args = Map::new();
    for (k, v) in [
        ("path", path),
        ("base_version", base),
        ("old", old),
        ("new", new),
    ] {
        args.insert(k.to_owned(), Value::String(v.to_owned()));
    }
    ToolCall {
        id: "call-1".to_owned(),
        name: ToolName::parse("edit").unwrap(),
        args: Payload::new(args).unwrap(),
    }
}

#[test]
fn an_edit_against_the_version_it_saw_lands_and_echoes_a_diff() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    std::fs::write(tmp.path().join("work/a.txt"), "one\ntwo\nthree\n").unwrap();
    let version = version_of(b"one\ntwo\nthree\n");
    let outcome = tool
        .invoke(&call("work/a.txt", &version, "two", "TWO"))
        .unwrap();
    let result = serde_json::to_value(&outcome.result).unwrap();
    assert_eq!(
        std::fs::read_to_string(tmp.path().join("work/a.txt")).unwrap(),
        "one\nTWO\nthree\n"
    );
    let diff = result["diff"].as_str().unwrap();
    assert!(diff.contains("-two"), "{diff}");
    assert!(diff.contains("+TWO"), "{diff}");
    assert_eq!(
        result["new_version"].as_str().unwrap(),
        version_of(b"one\nTWO\nthree\n")
    );
}

#[test]
fn a_file_that_moved_refuses_and_names_the_version_it_is_at_now() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    std::fs::write(tmp.path().join("work/a.txt"), "current\n").unwrap();
    let err = match tool.invoke(&call("work/a.txt", "0000000000000000", "current", "next")) {
        Err(err) => err,
        Ok(_) => panic!("a stale base_version must refuse"),
    };
    assert_eq!(*err.code(), AxCode::VersionConflict);
    assert!(err.subject().contains(&version_of(b"current\n")));
    // The file is untouched: a refused edit changes nothing.
    assert_eq!(
        std::fs::read_to_string(tmp.path().join("work/a.txt")).unwrap(),
        "current\n"
    );
}

#[test]
fn base_version_new_creates_the_file_and_parents() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    let outcome = tool
        .invoke(&call("work/room/notes.md", "new", "", "first line\n"))
        .unwrap();
    assert_eq!(
        std::fs::read_to_string(tmp.path().join("work/room/notes.md")).unwrap(),
        "first line\n"
    );
    let result = serde_json::to_value(&outcome.result).unwrap();
    assert_eq!(
        result["new_version"].as_str().unwrap(),
        version_of(b"first line\n")
    );
    assert!(result["diff"].as_str().unwrap().contains("+first line"));
}

#[test]
fn creating_over_an_existing_file_is_a_version_conflict_naming_the_real_version() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    std::fs::write(tmp.path().join("work/a.txt"), "already\n").unwrap();
    let err = match tool.invoke(&call("work/a.txt", "new", "", "other\n")) {
        Err(err) => err,
        Ok(_) => panic!("an existing file must refuse creation"),
    };
    assert_eq!(*err.code(), AxCode::VersionConflict);
    assert!(err.subject().contains(&version_of(b"already\n")));
    assert_eq!(
        std::fs::read_to_string(tmp.path().join("work/a.txt")).unwrap(),
        "already\n"
    );
}

#[test]
fn creating_with_a_nonempty_old_is_refused_with_the_form_to_use() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    let err = match tool.invoke(&call("work/b.txt", "new", "something", "content")) {
        Err(err) => err,
        Ok(_) => panic!("a create replaces nothing"),
    };
    assert_eq!(*err.code(), AxCode::InvalidArgs);
    assert!(err.recovery().contains("old:\"\""));
}

#[test]
fn a_path_outside_the_write_domain_is_refused_before_the_disk_is_touched() {
    let tmp = tempfile::tempdir().unwrap();
    let elsewhere = tempfile::tempdir().unwrap();
    let beyond = elsewhere.path().join("abs.txt");
    std::fs::write(tmp.path().join("outside.txt"), "x").unwrap();
    let tool = tool(tmp.path());
    for hostile in ["outside.txt", "elsewhere/f.md", ".sprawling/ledger/x"] {
        let err = match tool.invoke(&call(hostile, "new", "", "y")) {
            Err(err) => err,
            Ok(_) => panic!("{hostile} must be refused"),
        };
        assert_eq!(*err.code(), AxCode::OutsideWriteDomain, "{hostile}");
        // The three-part refusal comes from the kernel gate now, so the
        // way out is in its alternative rather than in a recovery line.
        let alternative = err
            .gate()
            .map(|gate| gate.alternative().to_owned())
            .unwrap_or_default();
        assert!(alternative.contains("work"), "{hostile}: {err}");
    }
    for illegal in ["../evil.txt", "work//x"] {
        let err = match tool.invoke(&call(illegal, "new", "", "y")) {
            Err(err) => err,
            Ok(_) => panic!("{illegal} must be refused"),
        };
        assert_eq!(*err.code(), AxCode::InvalidArgs, "{illegal}");
    }
    // A path this platform calls absolute is an address only inside the
    // city (runtime D4): outside it the refusal points at `exec`,
    // and the create form leaves nothing behind there.
    let err = match tool.invoke(&call(beyond.to_str().unwrap(), "new", "", "y")) {
        Err(err) => err,
        Ok(_) => panic!("{} must be refused", beyond.display()),
    };
    assert_eq!(
        (
            err.code(),
            err.recovery().contains("`exec`"),
            beyond.exists()
        ),
        (&AxCode::GateDenied, true, false),
        "{err:?}"
    );
    assert_eq!(
        std::fs::read_to_string(tmp.path().join("outside.txt")).unwrap(),
        "x"
    );
}

#[test]
fn a_missing_file_names_the_create_form_in_its_recovery() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    let version = version_of(b"whatever");
    let err = match tool.invoke(&call("work/absent.md", &version, "a", "b")) {
        Err(err) => err,
        Ok(_) => panic!("a missing file must refuse a non-create edit"),
    };
    assert!(err.recovery().contains("base_version:\"new\""), "{err}");
}

#[test]
fn zero_or_many_matches_refuse_with_the_count() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    std::fs::write(tmp.path().join("work/a.txt"), "x\nx\n").unwrap();
    let version = version_of(b"x\nx\n");
    let err = match tool.invoke(&call("work/a.txt", &version, "x", "y")) {
        Err(err) => err,
        Ok(_) => panic!("an ambiguous match must refuse"),
    };
    assert_eq!(*err.code(), AxCode::InvalidArgs);
    assert!(err.subject().contains("2 times"), "{}", err.subject());

    let err = match tool.invoke(&call("work/a.txt", &version, "absent", "y")) {
        Err(err) => err,
        Ok(_) => panic!("a missing match must refuse"),
    };
    assert!(err.subject().contains("0 times"), "{}", err.subject());
}

#[test]
fn a_call_for_another_tool_is_refused_not_routed() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    let mut wrong = call("work/a.txt", "v", "a", "b");
    wrong.name = ToolName::parse("exec").unwrap();
    let err = match tool.invoke(&wrong) {
        Err(err) => err,
        Ok(_) => panic!("identity is fail-closed"),
    };
    assert_eq!(*err.code(), AxCode::InvalidArgs);
}

#[test]
fn a_documents_tool_writes_markdown_and_refuses_code_by_name() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(tmp.path().join("hall")).unwrap();
    let hall = Address::parse("hall").unwrap();
    let domain = kernel::WriteDomain::documents(vec![hall.clone()]).unwrap();
    let tool = EditTool::new(
        tmp.path(),
        Address::parse("hall/mayor").unwrap(),
        domain,
        PolicyCell::new(RunPolicy::of(Mode::Work)).reader(),
    )
    .unwrap();
    tool.invoke(&call(
        "hall/note.md",
        "new",
        "",
        "# note
",
    ))
    .unwrap();
    assert!(tmp.path().join("hall/note.md").is_file());
    let refused = tool
        .invoke(&call("hall/lex.rs", "new", "", "fn main() {}"))
        .unwrap_err();
    assert_eq!(*refused.code(), AxCode::OutsideWriteDomain);
    assert!(
        refused.subject().contains("hall/lex.rs"),
        "the refusal names the file, not the room"
    );
    let plan = tool
        .invoke(&call("hall/Roadmap.md", "new", "", "| 0 |"))
        .unwrap_err();
    assert_eq!(*plan.code(), AxCode::OutsideWriteDomain);
}

/// Places a junction at `to` leading to `from` (a symlink off
/// Windows). `false` when this machine hands out no link at all.
#[cfg(windows)]
fn place_link(_file: bool, from: &Path, to: &Path) -> bool {
    std::process::Command::new("cmd")
        .args([
            "/c",
            "mklink",
            "/J",
            &to.display().to_string(),
            &from.display().to_string(),
        ])
        .output()
        .is_ok_and(|out| out.status.success())
}

#[cfg(not(windows))]
fn place_link(_file: bool, from: &Path, to: &Path) -> bool {
    std::os::unix::fs::symlink(from, to).is_ok()
}

/// The acceptance of the protected-metadata rule at a run's own write
/// face: `work/alias-dir/pre-run` crossing a junction into
/// `.git/hooks` is privilege escalation through a name the write-domain
/// gate already allowed. The write is refused whole, and the hook keeps
/// its bytes.
#[test]
fn an_edit_through_a_link_to_a_reserved_path_is_refused() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
    let hook = tmp.path().join(".git").join("hooks").join("pre-run");
    std::fs::write(&hook, b"hook-body").unwrap();
    if !place_link(
        true,
        &tmp.path().join(".git").join("hooks"),
        &tmp.path().join("work").join("alias-dir"),
    ) {
        return;
    }

    let version = version_of(b"hook-body");
    let refused = tool
        .invoke(&call(
            "work/alias-dir/pre-run",
            &version,
            "hook-body",
            "escalated",
        ))
        .unwrap_err();
    assert_eq!(*refused.code(), AxCode::OutsideWriteDomain);
    assert_eq!(
        std::fs::read(&hook).unwrap(),
        b"hook-body",
        "the reserved file the link reaches is byte-identical"
    );
}

/// Creating below a name that is a link must not make a single
/// directory at the link's target. The alias check has to run before
/// `create_dir_all`, or a protected subtree gains a directory through a
/// name the write-domain gate already allowed (`crates/storage/Spec.lean` ยง8-25).
#[test]
fn creating_below_a_link_leaves_the_link_target_untouched() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    let hooks = tmp.path().join(".git").join("hooks");
    std::fs::create_dir_all(&hooks).unwrap();
    if !place_link(true, &hooks, &tmp.path().join("work").join("alias-dir")) {
        return;
    }

    let refused = tool
        .invoke(&call("work/alias-dir/sub/x.md", CREATES, "", "body"))
        .unwrap_err();
    assert_eq!(*refused.code(), AxCode::OutsideWriteDomain);
    assert_eq!(
        std::fs::read_dir(&hooks).unwrap().count(),
        0,
        "no directory is made through the link"
    );
}

/// The hard-link arm: whatever verdict an edit whose name shares an
/// inode with a reserved file is greeted with, the reserved file is
/// whole afterwards - the write never lands in it.
#[test]
fn an_edit_through_a_hard_link_leaves_the_reserved_file_whole() {
    let tmp = tempfile::tempdir().unwrap();
    let tool = tool(tmp.path());
    std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
    let hook = tmp.path().join(".git").join("hooks").join("pre-run");
    std::fs::write(&hook, b"hook-body").unwrap();
    if !std::fs::hard_link(&hook, tmp.path().join("work").join("a.txt")).is_ok() {
        return;
    }

    let version = version_of(b"hook-body");
    let outcome = tool.invoke(&call("work/a.txt", &version, "hook-body", "escalated"));
    if outcome.is_ok() {
        assert_eq!(
            std::fs::read_to_string(tmp.path().join("work").join("a.txt")).unwrap(),
            "escalated",
            "the write lands at the name it was given"
        );
    }
    assert_eq!(
        std::fs::read(&hook).unwrap(),
        b"hook-body",
        "the reserved file behind the other name is byte-identical: the write never landed"
    );
}

/// Where a person keeps a city is their placement, not a write a run
/// could redirect: a city reached through a link above its root - macOS
/// puts every temporary directory under `/var`, which is a link to
/// `/private/var` - is edited like any other (`crates/storage/Spec.lean` ยง8-12).
#[test]
fn a_city_placed_below_a_link_is_edited_like_any_other() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(tmp.path().join("real")).unwrap();
    if !place_link(true, &tmp.path().join("real"), &tmp.path().join("placed")) {
        return;
    }
    let city = tmp.path().join("placed");
    let tool = tool(&city);
    std::fs::write(city.join("work/a.txt"), "draft\n").unwrap();

    let edited = tool.invoke(&call(
        "work/a.txt",
        &version_of(b"draft\n"),
        "draft",
        "final",
    ));
    let created = tool.invoke(&call("work/b.txt", "new", "", "made\n"));

    assert_eq!(
        (
            edited.map(|_| ()),
            created.map(|_| ()),
            std::fs::read_to_string(tmp.path().join("real/work/a.txt")).unwrap(),
            std::fs::read_to_string(tmp.path().join("real/work/b.txt")).unwrap(),
        ),
        (Ok(()), Ok(()), "final\n".to_owned(), "made\n".to_owned())
    );
}