main
Drew Owens 0.9.2 (707f990): two versions of the ledger or the ownership registry are merged, not picked; small legacy files are ha… 1670e34 20d ago
//! ADVERSARIAL REVIEW OF 0.9.1 — pass forty.
//!
//! 0.9.1 is PUBLISHED. Every pass-39 finding landed, and the two that mattered
//! landed well: the data root left ab's directory for
//! `%LOCALAPPDATA%\adom-solidworks-bridge\`, and a legacy file is no longer
//! settled by name. Nothing below makes 0.9.1 worse than 0.9.0.
//!
//! WHAT CHECKS OUT
//!
//!  (1) `sha256.rs` is correct at every padding boundary — 0, 1, 55, 56, 57, 63,
//!      64, 65, 119, 120, 121, 127, 128 bytes, page sizes, 1 MiB and 1 MiB + 1 —
//!      against digests computed by Python's `hashlib` for this pass, both in
//!      one call and streamed in awkward chunk sizes (pinned below). It is used
//!      in exactly two places, both in `scratch.rs` and both for the MIGRATION
//!      RECORD: the digest of a copy after it has been byte-verified with
//!      `same_bytes` and renamed. Verification still never depends on it, and it
//!      is nowhere near the recovery marker in `host.rs`.
//!  (4) The never-ask pin now walks the whole repository and strips quotes only
//!      in a NAMED allowlist of five describing files; `references/*.md` is not
//!      on it. Measured: a blockquoted ask and a backticked ask planted in
//!      `references/closing-and-recovery.md` are each caught.
//!  (5) `data_root_is_temp` and `recovery_inside_install_dir` both refuse before
//!      `recovery_dir()` is even called, so before the first SaveAs. A temp root
//!      cannot strand a half-done sweep.
//!  (6) The rewritten no-profile test blanks `SWX_SCRATCH_DIR`, `LOCALAPPDATA`
//!      and `USERPROFILE`; `env_path` treats empty as unset, so the refusal is
//!      reached through the production `root_and_source`. `TMP`/`TEMP`/`TMPDIR`
//!      only choose WHERE temp is. Still pinned on the real path.
//!  (2, partly) `migrated-from-legacy.json` is NOT in `MIGRATED_ITEMS`, so 0.9.1
//!      never inherits 0.9.0's mtime-less records; every record it compares
//!      against was written by itself, with an mtime.

mod common;
use common::*;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

fn s(v: &Value) -> String {
    serde_json::to_string_pretty(v).unwrap_or_default()
}

fn write(path: &Path, bytes: &[u8]) {
    if let Some(p) = path.parent() {
        std::fs::create_dir_all(p).unwrap();
    }
    std::fs::write(path, bytes).unwrap();
}

fn set_mtime(path: &Path, t: SystemTime) {
    std::fs::File::options().write(true).open(path).unwrap().set_modified(t).unwrap();
}

/// `%LOCALAPPDATA%` with ab's layout and both legacy roots under it.
struct Layout {
    local: TempDir,
}
impl Layout {
    fn new(tag: &str) -> Layout {
        Layout { local: TempDir::new(tag) }
    }
    fn data(&self) -> PathBuf {
        self.local.0.join("adom-solidworks-bridge")
    }
    fn cache(&self) -> PathBuf {
        self.local.0.join("Adom Bridge").join("bridges-cache").join("solidworks")
    }
    fn v090(&self) -> PathBuf {
        self.local.0.join("Adom Bridge").join("solidworks-data")
    }
}

// ── (1) SHA-256 — PINNED AGAINST AN INDEPENDENT IMPLEMENTATION ──────────────

/// Input of length `n`: deterministic and non-repeating within a block, so a
/// mis-ordered word or a wrong schedule index cannot hide behind uniform bytes.
fn data(n: usize) -> Vec<u8> {
    (0..n).map(|i| ((i * 31 + 7) % 251) as u8).collect()
}

/// Digests computed by Python's `hashlib.sha256` over `data(n)` for this pass.
/// Every length on either side of a padding boundary: 55/56/57 (the length field
/// fits / does not fit in the last block), 63/64/65 and 119/120/121 (one block,
/// two blocks, the second boundary), 127/128, page sizes, and 1 MiB on both
/// sides — the streaming buffer in `hex_of_file` is exactly 1 MiB.
const HASHLIB: &[(usize, &str)] = &[
    (0, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"),
    (1, "ca358758f6d27e6cf45272937977a748fd88391db679ceda7dc7bf1f005ee879"),
    (55, "e40272620990d5f772101e014a0fe168d9ab13215739a04bdcb4b9cadc303c75"),
    (56, "5b3154594d65c2a11e797576e2b4ece47f48373d8acfc0ae2cc3b87ca7adaedc"),
    (57, "bd73274967a968a1d4353b4096741c1c880d2356d44c63f0e7916c8efe59bccc"),
    (63, "2caeed71c024d190f2f9633bdac2b8f283b1cb86dfe7709023ab70f11f747a1b"),
    (64, "3483dc12a3fea6618642162f88124ea56e797597a97483d5ba597a42d98aaf6b"),
    (65, "3a513663aae909c328f43e2d74265dc5ec35495c99dfde7b8f9dd37f297dfa40"),
    (119, "c06b1fcfb8d206bb4fe8e8cde10de7af236f619ff29c720de0083f7377491fd9"),
    (120, "f425d2567f5c636c99335e5e8bb04669475b3b8b6f7523bbabbd11115cef8550"),
    (121, "a75b2c7abda3c75c6be3edc54578301604c241b5cc1b71f060e78d019624610f"),
    (127, "2388ace1b8d638348c6720ec543c3dc1a2db3d73bf5c5f029536827b27b9b45f"),
    (128, "43a42610c7b657d1ce21bf0255e86be7ea01aa9dddf0701f596e99a4c6f61001"),
    (1000, "008549d94fa71e7a0a483d84380d05a923a4b18e79ba1f8a8ddac923956d32ef"),
    (4095, "efd2adfc2f8f96eab7aaf2245689e402dbe886ec91065947cbc4fd79297cd337"),
    (4096, "5ab775379b00e0ca28b6ee8c0b71b2e54307875de77c156356740d946a181be6"),
    (65535, "7dad0dd6a5a9d94d5c0af761d89d4def9e9bb415be9838d72700e702eb03b8f9"),
    (65536, "c2a19b29e9a734066ffb748d00176ca95e52545a0b0afe9e73f085740aeb97f8"),
    (1048576, "1c59b8670027384143781a8a8bff2f3b44bd8818d0f53b13b064c2375a1afe38"),
    (1048577, "455ced47859eda55f31afb1ed6e145648da8b8a50cc2fa1059abc4e356838615"),
];

/// Passes today. It exists so the hand-written SHA-256 stays correct at every
/// boundary its own tests do not name, in one call, streamed in awkward chunk
/// sizes, and through the file path the migration actually uses.
#[test]
fn sha256_matches_hashlib_at_every_padding_boundary() {
    use swx_bridge::sha256::{hex_of, hex_of_file, Sha256};
    let dir = TempDir::new("adv38-sha");
    for &(n, want) in HASHLIB {
        let d = data(n);
        assert_eq!(hex_of(&d), want, "one-shot digest of {n} bytes");
        for chunk in [1usize, 3, 55, 63, 64, 65, 4097] {
            let mut h = Sha256::new();
            for c in d.chunks(chunk) {
                h.update(c);
            }
            assert_eq!(h.hex(), want, "{n} bytes streamed in {chunk}-byte pieces");
        }
        let f = dir.join(&format!("v{n}.bin"));
        std::fs::write(&f, &d).unwrap();
        assert_eq!(hex_of_file(&f).as_deref(), Some(want), "file digest of {n} bytes");
    }
}

// ── (3) THE TWO-ROOT POLICY ─────────────────────────────────────────────────

/// SEVERITY 2 — "THE OLDER ROOT KEEPS THE REAL NAME" GIVES IT TO THE STALE COPY.
///
/// Legacy roots are processed oldest first — `bridges-cache\solidworks` (0.8.x),
/// then `Adom Bridge\solidworks-data` (0.9.0) — and a same-named file with
/// different bytes in the second is copied BESIDE the first as
/// `<name>.legacy-<stamp>`. "Older ROOT" is a proxy for "older FILE", and on
/// Drew's machine the proxy is backwards. The 2026-09-18 update wiped
/// `bridges-cache\solidworks`; 0.8.9 then RE-CREATED its ledger and registry
/// there; 0.9.0 copied those into `solidworks-data` and kept APPENDING. So
/// `solidworks-data\ledger.jsonl` is a strict superset of the cache's, and its
/// `owned-docs.json` is the newer registry. 0.9.1 gives the real names to the
/// cache's copies, and the 0.9.0 history goes into a side file nothing reads.
///
/// Nothing is DROPPED — the policy is honest about that, and recovery folders
/// are unaffected (a sweep folder present in both roots is byte-identical, so
/// the second is recorded `present`). What regresses is the LIVE state:
/// `swx_action_ledger` reads `ledger.jsonl`, so the 0.9.0 era vanishes from the
/// audit log; `owned::restore` reads `owned-docs.json`, so documents 0.9.0
/// created are no longer recognised as the bridge's own (fail-safe: left open).
///
/// AND "NEWEST MTIME WINS" IS NOT THE FIX. That is right for the history above
/// (0.9.0's copy is a superset and newer) and wrong for the one described for
/// Drew's machine: a cache RE-CREATED by 0.8.9 after 0.9.0 wrote its own data,
/// newer by mtime and stale in content. There the two ledgers are DIVERGENT —
/// each holds entries the other lacks — and any rule that picks ONE file for the
/// real name drops one history from the live ledger. An append-only ledger needs
/// a MERGE (dedupe by line, order by `at`); a registry needs a UNION (every
/// claim is re-validated against the live session anyway). The next test pins
/// the divergent case so a mtime rule cannot pass for the wrong reason.
#[test]
fn the_live_ledger_after_migration_is_the_newest_one_not_the_oldest_roots() {
    let lay = Layout::new("adv38-tworoots");
    let old = SystemTime::now() - Duration::from_secs(3 * 86_400);
    let new = SystemTime::now() - Duration::from_secs(3_600);
    let base = "{\"at\":1789000000,\"action\":\"document_created\",\"detail\":{\"title\":\"CACHE-ERA\"}}\n";
    let superset = format!(
        "{base}{{\"at\":1790800000,\"action\":\"close_all_safely\",\"detail\":{{\"title\":\"V090-ERA\"}}}}\n"
    );
    write(&lay.cache().join("ledger.jsonl"), base.as_bytes());
    set_mtime(&lay.cache().join("ledger.jsonl"), old);
    write(&lay.v090().join("ledger.jsonl"), superset.as_bytes());
    set_mtime(&lay.v090().join("ledger.jsonl"), new);

    let local = lay.local.path();
    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);
    let _ = b.call("swx_recover_list", json!({}));
    drop(b);

    let live = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();
    assert!(live.contains("CACHE-ERA"), "precondition: the migration ran: {live:?}");
    let side: Vec<String> = std::fs::read_dir(lay.data())
        .map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect())
        .unwrap_or_default();

    assert!(
        live.contains("V090-ERA"),
        "the live ledger.jsonl after migration holds the 0.8.x cache's history and NOT the newer \
         0.9.0 entries, although 0.9.0's ledger is a strict superset with the later mtime. \"The \
         older ROOT keeps the real name\" gave the name to the STALE file: on Drew's machine the \
         cache was wiped on 09-18 and re-created by 0.8.9, then copied and appended to by 0.9.0. \
         The 0.9.0 era is in a side file swx_action_ledger never reads, and the same rule hands \
         owned-docs.json to the older registry. Merge the ledgers; union the registries. \
         \n  live ledger: {live:?}\n  data root: {side:?}"
    );
}

/// THE DIVERGENT CASE — both roots hold entries the other lacks, and the OLDER
/// root's file is the NEWER one by mtime (a cache re-created by 0.8.9 after 0.9.0
/// had written its own data). Today the cache keeps the real name; a "newest
/// mtime wins" fix would keep it too. Either way the 0.9.0 entry leaves the live
/// ledger. Only a merge passes this.
#[test]
fn divergent_ledgers_in_the_two_roots_are_merged_not_picked() {
    let lay = Layout::new("adv38-divergent");
    let older = SystemTime::now() - Duration::from_secs(2 * 86_400);
    let newer = SystemTime::now() - Duration::from_secs(600);
    write(
        &lay.cache().join("ledger.jsonl"),
        b"{\"at\":1790900000,\"action\":\"document_closed\",\"detail\":{\"title\":\"ONLY-IN-CACHE\"}}\n",
    );
    set_mtime(&lay.cache().join("ledger.jsonl"), newer);
    write(
        &lay.v090().join("ledger.jsonl"),
        b"{\"at\":1790800000,\"action\":\"close_all_safely\",\"detail\":{\"title\":\"ONLY-IN-V090\"}}\n",
    );
    set_mtime(&lay.v090().join("ledger.jsonl"), older);

    let local = lay.local.path();
    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);
    let _ = b.call("swx_recover_list", json!({}));
    drop(b);

    let live = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();
    assert!(
        live.contains("ONLY-IN-CACHE") || live.contains("ONLY-IN-V090"),
        "precondition: the migration ran: {live:?}"
    );
    assert!(
        live.contains("ONLY-IN-CACHE") && live.contains("ONLY-IN-V090"),
        "two DIVERGENT ledgers — each with an entry the other lacks — and the live ledger keeps \
         only one of them. Picking a file by root age drops one history; picking by mtime would \
         drop the other one in this case. An append-only ledger has to be merged. \
         live ledger: {live:?}"
    );
}

// ── (2) CHANGE DETECTION ────────────────────────────────────────────────────

/// SEVERITY 3 — THE LEGACY SIDE IS NEVER HASHED, SO A SAME-SIZE, SAME-MTIME
/// REWRITE IS SETTLED.
///
/// `differs` compares SIZE and MTIME only. The recorded sha256 is the COPY's; it
/// is never compared with the legacy file's current bytes. Two consequences:
///
///   * a rewrite that keeps the length and the mtime is settled silently. A
///     bridge writing `owned-docs.json` cannot cause that — `write_atomic`
///     makes a new file with a new mtime — so the ordinary rollback IS caught.
///     What causes it is anything that RESTORES mtimes: a File History or
///     backup restore, `robocopy /COPY:DAT`, a sync client re-materialising a
///     file. Equal length is the norm for this registry: scratch names are
///     `Part<N>-<13-digit epoch ms>.SLDPRT`, so swapping one entry for another
///     rarely changes the byte count.
///   * mtime granularity is a false-positive risk, not a false-negative one:
///     FAT/exFAT's 2 s and a copy tool rounding to it change the mtime without
///     changing the bytes, which copies the file again beside itself. Harmless,
///     noisy.
///
/// The ledger is append-only, so its size always moves: safe. The registries are
/// a few KB: hash them. Re-hashing the legacy side of any file under a few MB on
/// a size+mtime match costs milliseconds and turns "probably the same" into
/// "the same".
#[test]
fn a_same_size_same_mtime_rewrite_of_a_legacy_registry_is_not_settled() {
    let lay = Layout::new("adv38-samesize");
    let reg = lay.cache().join("owned-docs.json");
    let a = r#"{"documents":[{"title":"Part1.SLDPRT","path":"C:\\x\\Part1-1790000000001.SLDPRT"}]}"#;
    let b = r#"{"documents":[{"title":"Part2.SLDPRT","path":"C:\\x\\Part2-1790000000002.SLDPRT"}]}"#;
    assert_eq!(a.len(), b.len(), "precondition: an equal-length rewrite");
    write(&reg, a.as_bytes());
    let t = SystemTime::now() - Duration::from_secs(7_200);
    set_mtime(&reg, t);

    let local = lay.local.path();
    let env = [("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())];
    {
        let br = Bridge::start_with(&env);
        let _ = br.call("swx_recover_list", json!({}));
    }
    let first = std::fs::read_to_string(lay.data().join("owned-docs.json")).unwrap_or_default();
    assert!(first.contains("Part1-1790000000001"), "precondition: the first copy landed: {first:?}");

    // A restore puts different bytes back with the ORIGINAL mtime.
    write(&reg, b.as_bytes());
    set_mtime(&reg, t);
    {
        let br = Bridge::start_with(&env);
        let _ = br.call("swx_recover_list", json!({}));
    }

    let mut seen = String::new();
    if let Ok(rd) = std::fs::read_dir(lay.data()) {
        for e in rd.flatten() {
            if e.file_name().to_string_lossy().starts_with("owned-docs.json") {
                seen.push_str(&std::fs::read_to_string(e.path()).unwrap_or_default());
            }
        }
    }
    let marker = std::fs::read_to_string(lay.data().join("migrated-from-legacy.json")).unwrap_or_default();
    assert!(
        seen.contains("Part2-1790000000002") || marker.contains("Part2-1790000000002"),
        "the legacy owned-docs.json was rewritten with DIFFERENT bytes of the SAME length and \
         its mtime put back — what a backup restore or robocopy /COPY:DAT does — and 0.9.1 \
         settled it: `differs` compares size and mtime, and the sha256 it records is the COPY's, \
         never compared with the legacy file. Equal length is the norm for this registry \
         (Part<N>-<13-digit epoch>). Hash the legacy side of small files on a size+mtime match.\
         \n  marker: {marker}"
    );
}

// ── (5) AN UNWRITABLE RECOVERY FOLDER ───────────────────────────────────────

fn a_session_with_unsaved_work(root: &Path) {
    std::fs::create_dir_all(root).unwrap();
    std::fs::write(
        root.join("mock-solidworks-open-docs.json"),
        json!({
            "running": true, "untitled": 1, "active": "untitled:a",
            "docs": [
                {"docType": "part", "path": "untitled:a", "title": "Imported.step.SLDPRT",
                 "saved": false, "dirty": true},
                {"docType": "part", "path": "C:\\Users\\drew\\work\\bracket.SLDPRT",
                 "title": "bracket.SLDPRT", "saved": true, "dirty": true}
            ],
            "files": {}
        })
        .to_string(),
    )
    .unwrap();
}

/// SEVERITY 3 — A RECOVERY FOLDER THAT CANNOT BE CREATED IS DISCOVERED ONE SAVE
/// AT A TIME, NOT REFUSED BEFORE THE FIRST.
///
/// The temp-root and inside-install refusals fire before anything is written.
/// Neither covers the case the coordinator named: a root whose `recovery` folder
/// cannot be created — `USERPROFILE` set but unwritable, a permissions or quota
/// problem, a file in the way. `recovery_dir()` calls
/// `let _ = std::fs::create_dir_all(&first); return first;` — the error is
/// DROPPED, the path returned as if it existed, and the sweep proceeds to issue
/// one `SaveAs3` per document into a folder that is not there.
///
/// It is SAFE — each save fails, each document is `preserved:false` and stays
/// open, nothing is closed. The cost is everything else: N doomed COM calls on a
/// SOLIDWORKS that may already be struggling (on a session of leaked import
/// temporaries N is in the hundreds), N identical failure records instead of one
/// cause, a manifest that cannot be written, and a reply that reads like N
/// separate save problems. One `create_dir_all(...)?` and a `recovery_dir_unwritable`
/// refusal, before the first SaveAs, says it once.
#[test]
fn a_recovery_folder_that_cannot_be_created_is_refused_before_the_first_save() {
    let scratch = TempDir::new("adv38-unwritable");
    a_session_with_unsaved_work(&scratch.0);
    // A FILE where the recovery folder must go: create_dir_all cannot succeed,
    // for any user, root included.
    std::fs::write(scratch.join("recovery"), b"not a directory").unwrap();
    let machine = TempDir::new("adv38-unwritable-machine");
    std::fs::write(machine.join("windows.json"), "[]").unwrap();
    let b = Bridge::start_in(
        TempDir::borrowed(&scratch.0),
        &[("SWX_LAUNCH_FAKE_DIR", &machine.path())],
    );
    b.call("swx_start", json!({}));
    let before = b.call("swx_docs", json!({}));
    let r = b.call("swx_close_all_safely", json!({"reason": "closing"}));
    let after = b.call("swx_docs", json!({}));
    assert!(
        before["count"].as_u64().unwrap_or(0) >= 2 && before["count"] == after["count"],
        "precondition (the safe half): nothing was closed. before {} after {}",
        s(&before),
        s(&after)
    );

    let attempts = r["recovered"].as_array().map(|a| a.len()).unwrap_or(0);
    assert!(
        attempts == 0 && r["success"] == json!(false),
        "the recovery folder could not be created — a FILE sits where it must go — and the sweep \
         did not refuse: it made {attempts} save attempt(s) into a folder that does not exist. \
         recovery_dir() drops create_dir_all's error and returns the path anyway. Safe (every \
         document stays open) but it costs one doomed SaveAs3 per document on a SOLIDWORKS that \
         may already be struggling, and reports N save failures instead of one cause. Refuse with \
         a single code before the first SaveAs. Reply: {}",
        s(&r)
    );
}