main
Drew Owens 0.6.9 source: launch resilience, honest counts, ownership persistence, drain, quit ladder — 21 adversary passes, 303 te… cf16621 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
//! The two-process protocol, end to end, with the REAL server driving the REAL
//! com-host child.
//!
//! The mock backend makes the CONTENT boring on purpose; what is under test
//! here is the plumbing between our own two processes, which is where a bridge
//! actually breaks on a user's machine:
//!
//!   * a child that dies (crash, or SOLIDWORKS taking it down) is replaced, and
//!     the NEXT verb works;
//!   * a child that writes junk on stdout is not allowed to wedge the server;
//!   * the child does not outlive its server;
//!   * a slow verb does not block the fast ones.

mod common;

use common::{Bridge, TempDir};
use serde_json::json;
use std::time::{Duration, Instant};

fn host_pid(b: &Bridge) -> u64 {
    let r = b.call("swx_status", json!({}));
    r["com"]["hostPid"].as_u64().unwrap_or_else(|| panic!("no com.hostPid in {r}"))
}

#[cfg(unix)]
fn kill_pid(pid: u64) {
    let out = std::process::Command::new("kill")
        .args(["-9", &pid.to_string()])
        .output()
        .expect("run kill");
    assert!(out.status.success(), "kill -9 {pid} failed: {out:?}");
}

/// Alive AND not a zombie: a killed child keeps its /proc entry until its
/// parent reaps it, and "the pid still has a directory" would let this test
/// pass on a corpse.
#[cfg(unix)]
fn process_alive(pid: u64) -> bool {
    match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
        Err(_) => false,
        // "<pid> (comm) <state> ..." — comm can contain spaces and parens, so
        // read the state from just after the LAST ')'.
        Ok(stat) => match stat.rfind(')') {
            Some(i) => !stat[i + 1..].trim_start().starts_with('Z'),
            None => false,
        },
    }
}

#[test]
#[cfg(unix)]
fn a_killed_com_host_is_replaced_and_the_next_verb_succeeds() {
    let b = Bridge::start();

    // Put state in the child so we can also see that it really is a NEW one.
    let r = b.call("swx_start", json!({}));
    check!(b, "start", r["success"] == true, r.to_string());
    let first = host_pid(&b);
    check!(b, "the com host is running", process_alive(first), first.to_string());

    kill_pid(first);
    // Give the server's reader thread a moment to notice the pipe close.
    std::thread::sleep(Duration::from_millis(300));
    check!(b, "the com host is dead", !process_alive(first), first.to_string());

    // THE point of this test: the very next verb works.
    let r = b.call("swx_start", json!({}));
    check!(b, "the next verb after a crash succeeds", r["success"] == true, r.to_string());

    let second = host_pid(&b);
    check!(
        b,
        "a genuinely new com host is serving",
        second != first && process_alive(second),
        format!("{first} -> {second}")
    );
    check!(
        b,
        "the respawn is reported in the log",
        b.log().contains("com host") && b.log().contains("respawn"),
        b.log()
    );
}

#[test]
#[cfg(unix)]
fn a_com_host_that_writes_junk_on_stdout_is_skipped_not_fatal() {
    // SWX_MOCK_NOISE makes the child emit a non-JSON line, an event line and a
    // stale-id response before every real answer — the three shapes the server
    // must skip.
    let b = Bridge::start_with(&[("SWX_MOCK_NOISE", "1")]);

    let r = b.call("swx_start", json!({}));
    check!(b, "start survives the noise", r["success"] == true, r.to_string());

    let r = b.call("swx_open", json!({"path": "C:\\mock\\gearbox.sldasm"}));
    check!(b, "open survives the noise", r["opened"]["type"] == "assembly", r.to_string());

    let r = b.call("swx_bom", json!({"format": "csv"}));
    check!(b, "bom survives the noise", r["rowCount"] == 3, r.to_string());

    // ...and nothing was mistaken for a real answer: the stale-id line carries
    // {"stale":true}, which must never appear in a response.
    check!(b, "no stale line leaked into a result", r.get("stale").is_none(), r.to_string());
}

#[test]
#[cfg(unix)]
fn the_com_host_does_not_outlive_its_server() {
    let pid = {
        let b = Bridge::start();
        b.call("swx_start", json!({}));
        let pid = host_pid(&b);
        assert!(process_alive(pid));
        pid
        // Bridge::drop kills the server here — as ab's `stopMethod: kill` does.
    };
    // The child's stdin closes with its parent, and it exits on EOF. No
    // orphaned process holding a COM object on the user's machine.
    let deadline = Instant::now() + Duration::from_secs(5);
    while process_alive(pid) && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(100));
    }
    assert!(!process_alive(pid), "com host {pid} outlived its server");
}

#[test]
#[cfg(unix)]
#[ignore = "process-tree/PID-namespace sensitive: the wedged child polls its \
            parent's liveness in whatever PID namespace it was spawned in, which \
            a sandboxed test observer cannot always see or signal into. Verified \
            to pass in a plain shell; run explicitly with `--ignored` on a normal \
            host. The graceful-shutdown orphan test above is the always-on cover."]
fn a_hard_killed_server_does_not_orphan_a_child_wedged_in_a_verb() {
    // The exact scenario the child-process design exists for: the server is
    // SIGKILL'd while the child is deep in a (here, mocked-slow) COM call. The
    // main loop only notices a dead parent at stdin EOF — between requests — so
    // the watchdog thread must be what exits the child. No orphan may remain
    // holding a COM object.
    let b = std::sync::Arc::new(Bridge::start_with(&[("SWX_MOCK_SLOW_MS", "60000")]));
    // Fast verb first to spawn the child and learn its pid.
    let child_pid = host_pid(&b);
    assert!(process_alive(child_pid));
    let server_pid = b.pid() as u64;

    // Fire the slow verb; it will wedge the child for 60s.
    {
        let b = std::sync::Arc::clone(&b);
        std::thread::spawn(move || {
            let _ = b.call("swx_start", json!({}));
        });
    }
    std::thread::sleep(Duration::from_millis(400));

    // Hard-kill the SERVER (as ab's forced stop, or a crash, would).
    kill_pid(server_pid);

    // The child's watchdog polls every 1s; give it a comfortable margin.
    let deadline = Instant::now() + Duration::from_secs(10);
    while process_alive(child_pid) && Instant::now() < deadline {
        std::thread::sleep(Duration::from_millis(200));
    }
    assert!(
        !process_alive(child_pid),
        "com host {child_pid} outlived its hard-killed server — the watchdog did not fire"
    );
}

#[test]
fn a_slow_verb_does_not_block_the_fast_ones() {
    // A 1.5s "cold start" in the mock, so swx_progress has something in flight
    // to report while the com host is busy.
    let b = std::sync::Arc::new(Bridge::start_with(&[("SWX_MOCK_SLOW_MS", "1500")]));
    let worker = {
        let b = std::sync::Arc::clone(&b);
        std::thread::spawn(move || b.call("swx_start", json!({})))
    };

    // Poll progress the way a UI would.
    let mut saw_active = false;
    let deadline = Instant::now() + Duration::from_secs(5);
    while Instant::now() < deadline && !worker.is_finished() {
        let r = b.call("swx_progress", json!({}));
        let active = r["active"].as_array().cloned().unwrap_or_default();
        if let Some(frame) = active.first() {
            check!(b, "the in-flight frame names its phase", frame["phase"] == "app_start", r.to_string());
            check!(b, "the in-flight frame has no percent yet", frame["percent"].is_null(), r.to_string());
            check!(b, "the in-flight frame counts elapsed time", frame["elapsedSec"].is_number(), r.to_string());
            saw_active = true;
            break;
        }
        std::thread::sleep(Duration::from_millis(50));
    }

    let started = worker.join().expect("slow verb thread");
    check!(b, "the slow verb finished", started["success"] == true, started.to_string());
    check!(b, "swx_progress answered while the com host was busy", saw_active, b.log());
    check!(
        b,
        "the slow verb took the time it claimed",
        started["progress"]["elapsedSec"].as_f64().unwrap_or(0.0) >= 1.4,
        started.to_string()
    );

    // ...and the registry is empty again afterwards.
    let r = b.call("swx_progress", json!({}));
    check!(b, "progress is idle once the verb returns", r["active"] == json!([]), r.to_string());
}

#[test]
fn the_com_host_child_speaks_the_protocol_directly() {
    // The other half of the contract: the child, driven by hand exactly as the
    // server drives it. If the framing ever changes, this fails without needing
    // a server at all.
    use std::io::{BufRead, BufReader, Write};
    // Its OWN scratch dir. The simulated SOLIDWORKS session is persisted there
    // (a com-host respawn must not close the user's documents, because a real
    // one does not), so a host sharing the default root would inherit whatever
    // another test left open.
    let scratch = TempDir::new("protocol-child");
    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", scratch.path())
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = BufReader::new(child.stdout.take().unwrap());

    let mut line = String::new();
    stdout.read_line(&mut line).expect("ready line");
    let ready: serde_json::Value = serde_json::from_str(&line).expect("ready is JSON");
    assert_eq!(ready["event"], "ready", "{line}");
    assert!(ready["pid"].is_u64(), "{line}");

    let read = |stdout: &mut BufReader<std::process::ChildStdout>| {
        let mut l = String::new();
        stdout.read_line(&mut l).expect("response line");
        serde_json::from_str::<serde_json::Value>(&l).unwrap_or_else(|e| panic!("{e}: {l}"))
    };

    writeln!(stdin, r#"{{"id":7,"cmd":"ping","args":{{}}}}"#).unwrap();
    let r = read(&mut stdout);
    assert_eq!(r["id"], 7, "responses echo the request id: {r}");
    assert_eq!(r["ok"], true, "{r}");
    assert_eq!(r["result"]["pong"], true, "{r}");

    writeln!(stdin, r#"{{"id":8,"cmd":"nope","args":{{}}}}"#).unwrap();
    let r = read(&mut stdout);
    assert_eq!(r["ok"], false, "{r}");
    assert!(
        r["commands"].as_array().map(|c| c.len() >= 15).unwrap_or(false),
        "an unknown command lists what IS available: {r}"
    );

    writeln!(stdin, "not json at all").unwrap();
    let r = read(&mut stdout);
    assert_eq!(r["ok"], false, "a malformed line gets an answer, not a hang: {r}");

    // Errors from the backend are reported, and the child keeps serving.
    writeln!(stdin, r#"{{"id":9,"cmd":"docs","args":{{}}}}"#).unwrap();
    let r = read(&mut stdout);
    assert_eq!(r["ok"], false, "{r}");
    assert!(r["error"].as_str().unwrap_or("").contains("not running"), "{r}");

    // ATTACH-vs-LAUNCH is modelled all the way down to the child: with no
    // launch flag, `start` only attaches — and there is nothing to attach to yet.
    writeln!(stdin, r#"{{"id":10,"cmd":"start","args":{{}}}}"#).unwrap();
    let r = read(&mut stdout);
    assert_eq!(r["ok"], false, "start without launch only ATTACHES: {r}");
    writeln!(stdin, r#"{{"id":11,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
    let r = read(&mut stdout);
    assert_eq!(r["ok"], true, "the child still works after an error: {r}");
    assert_eq!(r["result"]["wasLaunched"], true, "{r}");

    // A v0.3 verb over the RAW protocol, not just through the server: the
    // document that gets created has no path, which is the whole point of it.
    writeln!(stdin, r#"{{"id":12,"cmd":"new_document","args":{{"type":"part"}}}}"#).unwrap();
    let r = read(&mut stdout);
    assert_eq!(r["ok"], true, "{r}");
    assert_eq!(r["result"]["created"]["path"], "", "a new document has no path yet: {r}");
    assert_eq!(r["result"]["created"]["type"], "part", "{r}");

    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let status = child.wait().expect("child exits on __exit__");
    assert!(status.success(), "clean exit expected, got {status:?}");
}

#[test]
fn the_hosts_record_of_its_own_documents_survives_a_respawn() {
    // M5: `ComBackend::created` was per-process, and the com host is killed and
    // respawned BY DESIGN — that is the whole reason it is a child process. So
    // the first thing a respawn did was start refusing writes to the BRIDGE'S
    // OWN documents: fail-closed, but silently broken, and precisely after the
    // recovery path the architecture exists for.
    let dir = std::env::temp_dir().join(format!("swx-respawn-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();

    let spawn = || {
        std::process::Command::new(common::BIN)
            .arg("--com-host")
            .env("SWX_MOCK", "1")
            .env("SWX_SCRATCH_DIR", &dir)
            .env_remove("SWX_ALLOW_USER_WRITES")
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::piped())
            .spawn()
            .expect("spawn com host")
    };

    // First host: create a document and save it, so it has a path.
    let mut a = spawn();
    let mut ain = a.stdin.take().unwrap();
    let mut aout = std::io::BufReader::new(a.stdout.take().unwrap());
    let saved = dir.join("mine.sldprt");
    {
        use std::io::Write;
        let mut skip = String::new();
        std::io::BufRead::read_line(&mut aout, &mut skip).unwrap();
        writeln!(ain, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
        let _ = common::read_line_json(&mut aout);
        writeln!(ain, r#"{{"id":2,"cmd":"new_document","args":{{"type":"part"}}}}"#).unwrap();
        let _ = common::read_line_json(&mut aout);
        writeln!(
            ain,
            "{}",
            serde_json::json!({"id":3,"cmd":"save_as","args":{"outputPath": saved.to_string_lossy()}})
        )
        .unwrap();
        let r = common::read_line_json(&mut aout);
        assert_eq!(r["ok"], true, "the host saves its own document: {r}");
    }
    let _ = a.kill();
    let _ = a.wait();

    // A NEW host process — as the server spawns after a hang or a crash.
    let mut b = spawn();
    let mut bin = b.stdin.take().unwrap();
    let mut bout = std::io::BufReader::new(b.stdout.take().unwrap());
    {
        use std::io::Write;
        let mut skip = String::new();
        std::io::BufRead::read_line(&mut bout, &mut skip).unwrap();
        writeln!(bin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
        let _ = common::read_line_json(&mut bout);
        writeln!(
            bin,
            "{}",
            serde_json::json!({"id":2,"cmd":"open","args":{"path": saved.to_string_lossy(), "launch": true}})
        )
        .unwrap();
        let _ = common::read_line_json(&mut bout);
        writeln!(
            bin,
            "{}",
            serde_json::json!({"id":3,"cmd":"save","args":{"path": saved.to_string_lossy()}})
        )
        .unwrap();
        let r = common::read_line_json(&mut bout);
        assert_eq!(
            r["ok"], true,
            "a respawned host must still recognise the document IT created: {r}"
        );
        writeln!(bin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    }
    let _ = b.wait();
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn the_host_refuses_a_record_from_a_previous_solidworks_session() {
    // M1: the persisted record carried no session id and was cleared only on a
    // clean `stop`. After a crash, a `swx_kill`, or the user quitting
    // SOLIDWORKS, it survived — and their next `Part1` matched `host_owns()`.
    // Driven over the RAW protocol so the HOST alone is deciding; the server's
    // registry is not in this path at all.
    use std::io::Write;
    let dir = std::env::temp_dir().join(format!("swx-stale-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    // A record that claims Part1, stamped with a SOLIDWORKS session that is not
    // the one now running.
    std::fs::write(
        dir.join("com-host-created.json"),
        // The record names the user's file EXACTLY — same title and same path.
        // Only the SESSION differs, which is the whole point: without the
        // session stamp this record is indistinguishable from a legitimate one.
        serde_json::json!({
            "session": 424242,
            "documents": [{"title": "Part1.sldprt", "path": "C:\\users\\Part1.sldprt"}]
        })
            .to_string(),
    )
    .unwrap();

    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", &dir)
        .env_remove("SWX_ALLOW_USER_WRITES")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
    {
        let mut line = String::new();
        std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();
    }
    writeln!(stdin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
    let _ = common::read_line_json(&mut stdout);
    // The USER's own Part1 — the title the stale record claims.
    writeln!(stdin, r#"{{"id":2,"cmd":"open","args":{{"path":"C:\\users\\Part1.sldprt","launch":true}}}}"#).unwrap();
    let _ = common::read_line_json(&mut stdout);
    writeln!(
        stdin,
        r#"{{"id":3,"cmd":"props_set","args":{{"docName":"Part1.sldprt","properties":{{"x":"1"}}}}}}"#
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(
        r["ok"], false,
        "a record from another SOLIDWORKS session is not evidence of ownership: {r}"
    );
    assert!(
        r["error"].as_str().unwrap_or("").contains("com host refuses"),
        "and the refusal must be the HOST's own: {r}"
    );

    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let _ = child.wait();
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn the_host_vetoes_a_forged_open_for_writing() {
    // H1 over the RAW protocol: `open` is on the host's safe list, so
    // `is_mutating` has to special-case `readOnly:false` or the host never
    // authorizes it at all — and the server's refusal is then the only one.
    // A write lock on the user's file, and an older-version document converted
    // and dirtied in memory, is a write.
    use std::io::Write;
    let dir = std::env::temp_dir().join(format!("swx-openrw-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", &dir)
        .env_remove("SWX_ALLOW_USER_WRITES")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
    {
        let mut line = String::new();
        std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();
    }
    writeln!(stdin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
    let _ = common::read_line_json(&mut stdout);

    // Read-only is fine and needs nothing.
    writeln!(
        stdin,
        r#"{{"id":2,"cmd":"open","args":{{"path":"C:\\users\\theirs.sldprt","launch":true}}}}"#
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(r["ok"], true, "a read-only open needs no permission: {r}");

    // Forged authorization, for writing, on a file the host did not create.
    writeln!(
        stdin,
        r#"{{"id":3,"cmd":"open","args":{{"path":"C:\\users\\other.sldprt","launch":true,"readOnly":false,"allowUserWrites":true}}}}"#
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(r["ok"], false, "the host must veto a forged open-for-writing: {r}");
    assert!(
        r["error"].as_str().unwrap_or("").contains("com host refuses"),
        "and the refusal must be the HOST's own: {r}"
    );

    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let _ = child.wait();
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn the_host_resolves_a_pathless_document_by_title() {
    // The live failure, at the level it actually happened: the SERVER sends
    // `{docName:"Part1", path:null}` for a document that has never been saved,
    // and the host must find it. `GetOpenDocumentByName` wants a full path, so
    // the host walks the open documents and compares titles instead.
    use std::io::Write;
    let dir = std::env::temp_dir().join(format!("swx-pathless-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", &dir)
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
    {
        let mut line = String::new();
        std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();
    }
    writeln!(stdin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
    let _ = common::read_line_json(&mut stdout);
    writeln!(stdin, r#"{{"id":2,"cmd":"new_document","args":{{"type":"part"}}}}"#).unwrap();
    let made = common::read_line_json(&mut stdout);
    let title = made["result"]["created"]["title"].as_str().unwrap().to_string();
    assert_eq!(made["result"]["created"]["path"], "", "a new document has no path: {made}");

    // Exactly what the server sends: the title as docName, path explicitly null.
    writeln!(
        stdin,
        "{}",
        serde_json::json!({"id":3,"cmd":"design_info","args":{"docName": title, "path": null}})
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(r["ok"], true, "a pathless document must resolve by title: {r}");

    // ...and an empty-string path must be treated as NO PATH, not as a path
    // that "matches any document without one". That distinction is invisible
    // with a single unsaved document open, so open a SECOND and ask for it by
    // title: matching on the empty path would hand back the first.
    writeln!(stdin, r#"{{"id":4,"cmd":"new_document","args":{{"type":"part"}}}}"#).unwrap();
    let second = common::read_line_json(&mut stdout);
    let second_title = second["result"]["created"]["title"].as_str().unwrap().to_string();
    assert_ne!(second_title, title, "two distinct unsaved documents");
    writeln!(
        stdin,
        "{}",
        serde_json::json!({"id":5,"cmd":"design_info","args":{"docName": second_title, "path": ""}})
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(r["ok"], true, "an empty path is no path: {r}");
    assert_eq!(
        r["result"]["doc"]["title"], second_title,
        "the TITLE decides which document, not the empty path: {r}"
    );

    // A title nothing answers to gets a BRANCHABLE code, not just prose.
    writeln!(
        stdin,
        r#"{{"id":5,"cmd":"design_info","args":{{"docName":"NoSuchDoc"}}}}"#
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(r["ok"], false, "{r}");
    assert_eq!(r["errorCode"], "document_not_open", "the host must give a stable code: {r}");

    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let _ = child.wait();
    let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn the_host_owns_every_document_it_brings_into_existence() {
    // LIVE RUN 3's bug. `swx_import` opened exactly one document and the SERVER
    // tracked it — but the com host's own `created` record never learned about
    // it, so the host's independent veto refused to close the very document the
    // bridge had just imported. Three of the five failures were this one cause,
    // and it left an orphan on the machine.
    //
    // The defect was per-verb registration: `cmd_import` has THREE success
    // paths (LoadFile4, a handle recovered from ActiveDoc, and the LoadFile2
    // fallback) and only the first registered. Registration now happens in the
    // DISPATCH LOOP, from the `{created}`/`{imported}` a result carries, so no
    // branch of no command can forget.
    //
    // Driven over the raw protocol: the server is not in this path, so the
    // host's veto is the only thing deciding.
    use std::io::Write;
    let dir = std::env::temp_dir().join(format!("swx-imported-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let step = dir.join("import-me.step");
    std::fs::write(&step, b"solid\n").unwrap();

    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", &dir)
        .env_remove("SWX_ALLOW_USER_WRITES")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
    {
        let mut line = String::new();
        std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();
    }
    writeln!(stdin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
    let _ = common::read_line_json(&mut stdout);

    writeln!(
        stdin,
        "{}",
        serde_json::json!({"id":2,"cmd":"import","args":{"filePath": step.to_string_lossy()}})
    )
    .unwrap();
    let imported = common::read_line_json(&mut stdout);
    assert_eq!(imported["ok"], true, "the import succeeded: {imported}");
    let title = imported["result"]["imported"]["title"].as_str().unwrap().to_string();

    // THE ACCEPTANCE: the host must let the bridge close what it just imported.
    writeln!(
        stdin,
        "{}",
        serde_json::json!({"id":3,"cmd":"close_doc","args":{"docName": title, "save": false}})
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(
        r["ok"], true,
        "the host must own a document it imported — this is the run-3 failure: {r}"
    );

    // ...and it still refuses a document it did NOT bring into existence, so
    // the fix has not simply made the host credulous.
    writeln!(
        stdin,
        r#"{{"id":4,"cmd":"open","args":{{"path":"C:\\users\\theirs.sldprt","launch":true}}}}"#
    )
    .unwrap();
    let _ = common::read_line_json(&mut stdout);
    writeln!(
        stdin,
        r#"{{"id":5,"cmd":"close_doc","args":{{"docName":"theirs.sldprt","save":false,"allowUserWrites":true}}}}"#
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(r["ok"], false, "opening the user's document must not make it ours: {r}");

    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let _ = child.wait();
    let _ = std::fs::remove_dir_all(&dir);
}

/// The com host's veto must apply the SAME opt-in test the server does — and no
/// weaker one.
///
/// LIVE, 2026-09-03: Drew authorized closing his own leftover documents, the
/// bridge was restarted with the env opt-in, the call carried allowUserWrites +
/// confirm + reason, and the answer was *"the com host refuses to close_doc:
/// this document was not created by this bridge"*. Two defects met: the server
/// discarded the grant it had issued (covered in e2e), and the host re-checked
/// only `allowUserWrites` while the server checked all three — so the "second
/// independent veto" was a DIFFERENT, weaker test rather than the same one
/// reached independently. Driven over raw stdio because that is the only place
/// the host's own decision can be observed without the server in front of it.
#[test]
fn the_host_applies_the_whole_opt_in_chain_and_honours_it() {
    use std::io::Write;
    let dir = std::env::temp_dir().join(format!("swx-hostchain-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let users = dir.join("theirs.sldprt");
    std::fs::write(&users, b"not ours").unwrap();
    let users = users.to_string_lossy().to_string();

    // The env opt-in IS on for this host, so every refusal below is about the
    // per-call chain and nothing else.
    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", &dir)
        .env("SWX_ALLOW_USER_WRITES", "1")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
    {
        let mut line = String::new();
        std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();
    }
    writeln!(stdin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
    let _ = common::read_line_json(&mut stdout);
    let open = serde_json::json!({"id":2,"cmd":"open","args":{"path":&users,"launch":true}});
    writeln!(stdin, "{open}").unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(r["ok"], true, "the user's document opens read-only with no permission: {r}");

    // PARTIAL chains are refused, one missing element at a time. Each of these
    // used to be ACCEPTED by the host: it looked at `allowUserWrites` alone.
    for (id, args, missing) in [
        (10, serde_json::json!({"docName":"theirs.sldprt","save":false,"allowUserWrites":true}), "confirm"),
        (11, serde_json::json!({"docName":"theirs.sldprt","save":false,"allowUserWrites":true,"confirm":true}), "reason"),
        (12, serde_json::json!({"docName":"theirs.sldprt","save":false,"allowUserWrites":true,"confirm":true,"reason":"  "}), "a blank reason is no reason"),
        (13, serde_json::json!({"docName":"theirs.sldprt","save":false,"confirm":true,"reason":"why"}), "allowUserWrites"),
    ] {
        writeln!(stdin, "{}", serde_json::json!({"id":id,"cmd":"close_doc","args":args})).unwrap();
        let r = common::read_line_json(&mut stdout);
        assert_eq!(r["ok"], false, "a chain missing {missing} is not an opt-in: {r}");
        assert!(
            r["error"].as_str().unwrap_or("").contains("com host refuses"),
            "and the refusal is the HOST's own: {r}"
        );
    }

    // THE WHOLE CHAIN. This is the case Drew authorized and the bridge refused.
    writeln!(
        stdin,
        "{}",
        serde_json::json!({"id":20,"cmd":"close_doc","args":{
            "docName":"theirs.sldprt","save":false,
            "allowUserWrites":true,"confirm":true,"reason":"Drew asked me to close his leftovers"
        }})
    )
    .unwrap();
    let r = common::read_line_json(&mut stdout);
    assert_eq!(
        r["ok"], true,
        "the FULL opt-in chain closes a document the bridge did not create — an escape hatch \
         nobody can open is not a safety property: {r}"
    );

    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let _ = child.wait();
    let _ = std::fs::remove_dir_all(&dir);
}

/// ADVERSARY PASS 12, the OTHER layer. The server's write gate refuses a
/// destination it cannot stat — but the com host's veto is supposed to be
/// INDEPENDENT, and two vetoes that disagree about what "the destination is free"
/// means are one veto with extra steps.
///
/// This drives the com host directly, over its own stdio protocol, with the write
/// opt-in FULLY granted — so the server's refusal cannot be what stops it. The
/// host must refuse on its own, by the same rule, with the same code.
#[test]
fn the_com_hosts_own_veto_refuses_a_destination_it_cannot_stat() {
    use std::io::Write;
    use std::os::unix::fs::PermissionsExt;

    let dir = std::env::temp_dir().join(format!("swx-proto-dest-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(dir.join("share")).unwrap();
    // The user's file, behind a parent this process cannot traverse — what an
    // unreachable UNC share looks like to `Path::exists()`.
    std::fs::write(dir.join("share/user-part.SLDPRT"), b"the user's only copy").unwrap();
    std::fs::set_permissions(dir.join("share"), std::fs::Permissions::from_mode(0o000)).unwrap();
    let dest = dir.join("share/user-part.SLDPRT");
    assert!(!dest.exists(), "precondition: exists() says false for a file that is really there");

    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", &dir)
        // THE OPT-IN IS ON. Whatever refuses below is not the consent gate.
        .env("SWX_ALLOW_USER_WRITES", "1")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
    {
        let mut line = String::new();
        std::io::BufRead::read_line(&mut stdout, &mut line).unwrap();
    }
    writeln!(stdin, r#"{{"id":1,"cmd":"start","args":{{"launch":true}}}}"#).unwrap();
    let _ = common::read_line_json(&mut stdout);
    writeln!(stdin, r#"{{"id":2,"cmd":"new_document","args":{{"type":"part"}}}}"#).unwrap();
    let made = common::read_line_json(&mut stdout);
    let title = made["result"]["created"]["title"].as_str().unwrap_or("Part1").to_string();

    // A document the BRIDGE created, saved to a path the host cannot check, with
    // the full opt-in supplied.
    let req = serde_json::json!({
        "id": 3, "cmd": "save_as",
        "args": {
            "docName": title, "out": dest.to_string_lossy(), "copy": true,
            "allowUserWrites": true, "confirm": true,
            "reason": "the caller asked for this, in full"
        }
    });
    writeln!(stdin, "{req}").unwrap();
    let r = common::read_line_json(&mut stdout);
    let _ = std::fs::set_permissions(dir.join("share"), std::fs::Permissions::from_mode(0o755));

    assert_eq!(
        r["ok"], false,
        "the com host's OWN veto allowed a write to a destination it could not stat, with the \
         opt-in granted. The server refusing is not enough: the host is the layer that actually \
         issues the COM call, and it is the one that may not hold the user's network token: {r}"
    );
    assert_eq!(
        r["errorCode"], "destination_unverifiable",
        "...and it must refuse by the SAME rule and the SAME code as the server, or the two \
         layers are not enforcing one rule: {r}"
    );
    assert!(
        r["error"].as_str().unwrap_or("").contains("cannot check"),
        "...saying what it could not answer: {r}"
    );

    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let _ = child.wait();
    let _ = std::fs::remove_dir_all(&dir);
}

/// ADVERSARY PASS 12, sweep item. The com host and the server are SEPARATE
/// BINARIES and can be a mismatched pair — a half-applied update, a stale exe in
/// a cache directory. A field this server reads and that host never sends then
/// arrives as `false`, and one of those fields is `closeOutcomeUnknown`, where a
/// dropped `true` becomes "the outcome was determined; nobody needs to look".
///
/// So the handshake carries a version, and a skew is an explicit error.
#[test]
fn the_ready_handshake_carries_a_version_so_a_skew_cannot_be_silent() {
    use std::io::Write;
    let dir = std::env::temp_dir().join(format!("swx-proto-ver-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();

    let mut child = std::process::Command::new(common::BIN)
        .arg("--com-host")
        .env("SWX_MOCK", "1")
        .env("SWX_SCRATCH_DIR", &dir)
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn com host");
    let mut stdin = child.stdin.take().unwrap();
    let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
    // RAW LINES until the event. `common::read_line_json` deliberately SKIPS
    // event lines — and the event line is exactly what this test is about — so
    // using it here waits forever. Bounded, so a host that never says `ready`
    // FAILS this test instead of hanging it.
    let ready = {
        let mut found = serde_json::Value::Null;
        for _ in 0..20 {
            let mut line = String::new();
            if std::io::BufRead::read_line(&mut stdout, &mut line).unwrap_or(0) == 0 {
                break;
            }
            let v: serde_json::Value =
                serde_json::from_str(line.trim()).unwrap_or(serde_json::Value::Null);
            if v.get("event").is_some() {
                found = v;
                break;
            }
        }
        found
    };
    assert_eq!(ready["event"], "ready", "the first line is still the ready event: {ready}");
    assert_eq!(
        ready["version"].as_str(),
        Some(env!("CARGO_PKG_VERSION")),
        "the ready line must name the host's version, or the server cannot tell a matched pair \
         from one that will silently drop fields: {ready}"
    );
    writeln!(stdin, r#"{{"id":0,"cmd":"__exit__"}}"#).unwrap();
    let _ = child.wait();
    let _ = std::fs::remove_dir_all(&dir);
}