main
Drew Owens 0.9.1 (a62fe72): the data root moves out of ab's directory to %LOCALAPPDATA%\adom-solidworks-bridge; legacy files that… 192302d 20d ago
//! ADVERSARIAL REVIEW OF 0.9.0 — pass thirty-nine.
//!
//! 0.9.0 is PUBLISHED and adom-bridge 2.1.163 auto-installs it everywhere, so
//! the question that governs this pass is whether anything here makes the
//! release worse than the 0.8.9 it replaces. It does not. Everything below is
//! about what 0.9.0 does not yet protect, not about what it breaks.
//!
//! WHAT CHECKS OUT, read and — where compiled here — exercised.
//!
//!  * THE STARTUP ORDER. `migrate_legacy` runs after the bind (a duplicate
//!    instance exits before touching anything) and BEFORE `Ctx::new` restores
//!    the ownership registry, so nothing is written into the data root ahead of
//!    the migration and then used to "skip" the real legacy file.
//!    (server.rs:74; its own comment names exactly that hazard.)
//!  * CRASH BETWEEN RENAME AND MARKER. The marker is written once, after the
//!    loop. A start that dies after some renames re-finds those files on the next
//!    start, sees `to.exists()`, and records them `skipped: already present`.
//!    Never copied twice, never lost — the renamed copy was byte-verified before
//!    the rename. Right.
//!  * CAN IT DELETE AN ORIGINAL? No. Nothing in `migrate_legacy` writes to or
//!    removes from the legacy tree. Removal is confined to the `.migrating`
//!    temporary and EMPTY directories under `dest`, guarded by
//!    `d.starts_with(&dest)`; `rel` is built from `read_dir` entries, which can
//!    carry neither a separator nor `..`. A user deleting a copy leaves the
//!    original untouched and the file stays settled, as documented.
//!  * JUNCTIONS. `resolved()` canonicalises the nearest existing ancestor, so a
//!    link INSIDE the data root that points INTO the install dir is caught
//!    (the path resolves inside and is refused), and a link inside the install
//!    dir that points OUT to the data root is correctly allowed, because the
//!    bytes live outside. (What `refuse_inside_install` cannot see is the third
//!    case: whether whatever deletes the install dir FOLLOWS such a link. That is
//!    ab's deleter, not this bridge's check.)
//!  * `recover_list`'s five states. `notCopiedYet` is `verified: null`.
//!    `partialCopy` carries a `howToOpen`, but it says INCOMPLETE / "SOLIDWORKS
//!    is unlikely to open them" and never offers to open anything. One document
//!    CAN appear in two states: a `.migrating` temporary whose original has since
//!    been re-extracted away lists as `partialCopy` under `X.migrating` AND as
//!    GONE under `X` — and GONE says "moved or deleted by something else", which
//!    is not what happened; the bytes that exist are the entry beside it. Wording
//!    only, low.
//!  * `closedInPlace` (target 3). `closed` is matched with `same_path` on the FULL
//!    path, case- and separator-insensitive, and both sides come from the same
//!    sweep's `GetPathName`. Two in-place documents with one basename in two
//!    directories stay distinct; a case difference matches, as Windows means it.
//!    A document open twice would mark both records closed after one close — and
//!    is unreachable, because SOLIDWORKS refuses the second open (65536).
//!  * THE GATES (target 4). The fast-empty retry lives only in `ps()`; routed
//!    `verb()` calls never retry, so no swx verb is ever sent twice. Every `ps()`
//!    script in the drain gate is idempotent (fixed names, `-Force`, `Test-Path`
//!    guards) and echoes on success, so "exit 1, empty stdout AND stderr, under
//!    5 s" really does mean the shell never ran. The unwrap only fires on a
//!    DICT `result`, and no swx verb returns a top-level `result` dict (the only
//!    two are strings nested in rung objects), so it cannot eat a real reply. A
//!    non-dict `result` falls through to the wrapper, where `success` is absent,
//!    which every gate treats as failure: fail-safe, at the cost of the specific
//!    `errorCode` branch (e.g. the interconnect redirect).

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

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();
}

/// `%LOCALAPPDATA%` with ab's layout under it, as `tests/data_root.rs` builds it.
struct Layout {
    local: TempDir,
}
impl Layout {
    fn new(tag: &str) -> Layout {
        Layout { local: TempDir::new(tag) }
    }
    fn ab_install(&self) -> PathBuf {
        self.local.0.join("Adom Bridge")
    }
    // BUILDER NOTE (0.9.1): finding 1 is fixed by MOVING the data root out of
    // `Adom Bridge\` to a sibling, `%LOCALAPPDATA%\adom-solidworks-bridge`, so
    // `data()` follows it. Every test below that reads "the data root" means the
    // root the bridge uses, and that is now the sibling; 0.9.0's location became
    // the second legacy root, which tests/data_root.rs exercises as such. The
    // assertions themselves are unchanged.
    fn data(&self) -> PathBuf {
        self.local.0.join("adom-solidworks-bridge")
    }
    fn legacy(&self) -> PathBuf {
        self.ab_install().join("bridges-cache").join("solidworks")
    }
}

/// SEVERITY 1, CONDITIONAL ON ONE UNMEASURED FACT — THE DATA ROOT IS INSIDE THE
/// DIRECTORY AB INSTALLS ITSELF INTO.
///
/// What ab's own documentation says, quoted as CLAIMS (none of this is
/// measured here):
///
///   * `adom-bridge-installer`: "It installs to `%LOCALAPPDATA%\Adom Bridge\`"
///     and "The uninstaller lives at `%LOCALAPPDATA%\Adom Bridge\uninstall.exe`".
///   * `adom-bridge-sdk-publish`: bridges unzip "into
///     `%LOCALAPPDATA%\Adom Bridge\bridges-cache\<name>\`".
///   * `adom-bridge-sdk-manifest`: "ab removes its own `bridges-cache` copy of
///     you; everything outside that only you know about."
///   * NOT documented anywhere I can find: what ab's NSIS uninstaller does to the
///     rest of `%LOCALAPPDATA%\Adom Bridge\`. The installer skill describes
///     PREUNINSTALL (PATH strip, the `bridge_uninstall_all` cascade) and
///     POSTUNINSTALL stages, and says nothing about removing — or keeping — the
///     directory's other contents. (And the docs disagree about the cache
///     itself: `adom-bridge-bridges-guide` says updates land in
///     `~/.adom/bridges-cache/`. The live measurement matches sdk-publish.)
///
/// 0.9.0 moved the data root from `Adom Bridge\bridges-cache\solidworks\` to
/// `Adom Bridge\solidworks-data\`. That is out of the directory ab re-extracts
/// on every BRIDGE update — the measured SEV-1, and it is genuinely fixed — and
/// into the directory ab's own INSTALLER owns. The recovery folder is the only
/// copy of unsaved work; the ledger is the one artifact `swx_uninstall` promises
/// to outlive the bridge. Whether both survive an ab uninstall, an ab repair, or
/// the most common troubleshooting move there is — "delete the Adom Bridge
/// folder and reinstall" — now depends on the uninstaller of a different
/// product, whose behaviour for that directory is undocumented.
///
/// That is the same shape as the SEV-1 this release exists to fix, one level
/// up: data placed inside a directory whose lifecycle another component
/// controls. Measure it (one ab uninstall on a scratch profile, check the
/// folder), or move the root out of `Adom Bridge\` altogether — a sibling under
/// `%LOCALAPPDATA%` that nothing but this bridge will ever write, whose only
/// deleter is a human. The migration machinery is good enough to do the second
/// move cheaply, provided `legacy_root()` learns there are now two legacy roots.
///
/// SHOULD 0.9.0 STAY PUBLISHED: yes. Bridge updates happen every four hours
/// (per ab's docs); an ab uninstall is rare. 0.9.0 is strictly safer than
/// 0.8.9, and pulling it would put every machine back in the location that is
/// wiped on every update.
#[test]
fn the_data_root_is_outside_the_directory_ab_installs_itself_into() {
    let lay = Layout::new("adv37-placement");
    let local = lay.local.path();
    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);

    let r = b.call("swx_recover_list", json!({}));
    let root = r["recoveryRoot"].as_str().unwrap_or("").to_string();
    assert!(!root.is_empty(), "precondition: the listing names its root: {}", s(&r));
    assert!(
        Path::new(&root).starts_with(&lay.local.0),
        "precondition: the root was derived from the LOCALAPPDATA given: {root}"
    );

    let inside_ab = Path::new(&root).starts_with(lay.ab_install());
    assert!(
        !inside_ab,
        "the data root {root:?} is inside {:?}, the directory ab's INSTALLER owns — ab's docs: \
         \"It installs to %LOCALAPPDATA%\\Adom Bridge\\\" and \"The uninstaller lives at \
         %LOCALAPPDATA%\\Adom Bridge\\uninstall.exe\". 0.9.0 moved recovery out of the directory \
         ab wipes on every BRIDGE update, which is a real fix; it moved it into the directory ab's \
         uninstaller manages, whose treatment of anything it did not install is undocumented. \
         The recovery folder is the only copy of unsaved work. Same shape as the SEV-1 this \
         release fixes, one level up. Measure one ab uninstall, or move the root to a sibling of \
         `Adom Bridge` that only this bridge writes.",
        lay.ab_install()
    );
}

/// SEVERITY 2 — A LEGACY FILE THAT CHANGES AFTER ITS COPY IS SETTLED BY NAME AND
/// NEVER LOOKED AT AGAIN.
///
/// `migrated-from-legacy.json` settles a file by its RELATIVE PATH. Once
/// `ledger.jsonl` has been copied, it is in `copied` forever, and
/// `files.retain(|rel| !settled.contains(rel))` drops it before any comparison
/// happens. So:
///
///   1. 0.9.0 starts, copies the legacy `ledger.jsonl` — settled.
///   2. The user rolls back to 0.8.9 (the module comment designs for exactly
///      this: "a file that appeared in the legacy location since (an older
///      bridge run again, after a rollback)"). 0.8.9's root IS the legacy
///      location, so every close it performs APPENDS to that ledger, and every
///      document it creates REWRITES `owned-docs.json` there.
///   3. 0.9.0 comes back. The ledger and the registry are settled; the new
///      entries are not copied, not compared, not reported. The next bridge
///      update re-extracts the legacy directory and they are gone.
///
/// The rollback design handles NEW files and not CHANGED ones, and the two files
/// that change by nature — an append-only ledger and a rewritten registry — are
/// precisely the ones this loses. Recovery folders survive (a sweep writes a new
/// folder, so new files), which is why this is not SEV-1: no document's only
/// copy is lost. What is lost is the audit record of what was done to the
/// user's documents during the rollback, and ownership of what 0.8.9 created —
/// which fails safe, since an unowned document is left open, not closed.
///
/// Settle by (path, size, mtime), not path alone. A legacy file whose size or
/// mtime differs from the one recorded when it was copied is "changed since
/// copied": copy it beside the settled one under a distinct name (never
/// overwrite), or at minimum report it — `legacyChangedSinceCopy` — so the
/// listing can warn before the update takes it.
#[test]
fn a_legacy_file_that_changes_after_its_copy_is_not_silently_settled() {
    let lay = Layout::new("adv37-rollback");
    let local = lay.local.path();
    let legacy_ledger = lay.legacy().join("ledger.jsonl");
    write(
        &legacy_ledger,
        b"{\"at\":1789000000,\"action\":\"document_created\",\"detail\":{\"title\":\"BEFORE\"}}\n",
    );
    let env = [("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())];

    // 1. First start: the ledger is migrated and settled.
    {
        let b = Bridge::start_with(&env);
        let _ = b.call("swx_recover_list", json!({}));
    }
    let data_ledger = lay.data().join("ledger.jsonl");
    let first = std::fs::read_to_string(&data_ledger).unwrap_or_default();
    assert!(
        first.contains("BEFORE"),
        "precondition: the first start migrated the legacy ledger: {first:?}"
    );

    // 2. A rollback to 0.8.9 appends to the ledger IN THE LEGACY LOCATION.
    {
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new().append(true).open(&legacy_ledger).unwrap();
        f.write_all(
            b"{\"at\":1790000000,\"action\":\"document_closed\",\"detail\":{\"title\":\"DURING-ROLLBACK\"}}\n",
        )
        .unwrap();
    }

    // 3. 0.9.0 comes back.
    {
        let b = Bridge::start_with(&env);
        let _ = b.call("swx_recover_list", json!({}));
    }
    let after = std::fs::read_to_string(&data_ledger).unwrap_or_default();
    let marker = std::fs::read_to_string(lay.data().join("migrated-from-legacy.json"))
        .unwrap_or_default();

    let carried = after.contains("DURING-ROLLBACK");
    // STRUCTURAL, not a word search: some array in the marker whose key speaks of
    // a change must NAME this file. A bare `contains("changed")` would pass the
    // day an unrelated `why` string used the word — the wrong-reason pass this
    // review has committed four times this cycle.
    let reported = serde_json::from_str::<Value>(&marker)
        .ok()
        .and_then(|m| m.as_object().cloned())
        .map(|m| {
            m.iter().any(|(k, v)| {
                k.to_ascii_lowercase().contains("changed")
                    && v.as_array()
                        .map(|a| a.iter().any(|e| e.to_string().contains("ledger.jsonl")))
                        .unwrap_or(false)
            })
        })
        .unwrap_or(false);
    assert!(
        carried || reported,
        "a ledger entry 0.8.9 appended in the legacy location during a rollback was neither \
         copied into the data root nor reported anywhere: `ledger.jsonl` was settled by NAME on \
         the first start, so the second start drops it before comparing anything. The next \
         update re-extracts the legacy directory and the record of what was done to the user's \
         documents during the rollback is gone. Settle by (path, size, mtime) and report a \
         changed-since-copy file rather than skipping it.\n  data-root ledger now: {after:?}\n  \
         marker: {marker}"
    );
}

/// SEVERITY 3 — THE LAST-RESORT DATA ROOT IS A DIRECTORY WINDOWS CLEANS.
///
/// With neither `SWX_SCRATCH_DIR` nor `LOCALAPPDATA`, `root()` falls back to
/// `std::env::temp_dir().join("adom-bridge-swx-data")` — `%TEMP%` on Windows,
/// which Storage Sense and Disk Cleanup delete from on a schedule the user
/// configured for disposable files. `refuse_inside_install` checks the install
/// directory and ab's cache; nothing checks the fallback. For a user-context
/// process `LOCALAPPDATA` is essentially always set, so this is rare — but when
/// it fires, the recovery folder lands in the one standard location that is
/// cleaned BY DESIGN, with no warning in the log. The startup already prints a
/// WARNING when recovery is inside an install location; the fallback deserves
/// the same line, or a refusal.
/// BUILDER NOTE (0.9.1): adapted to the design decided for this finding — a
/// PROFILE directory before a temp one (`%USERPROFILE%\adom-solidworks-bridge`),
/// and a REFUSAL (`data_root_is_temp`, plus a start-up WARNING) when temp is all
/// there is. As written, this test unset LOCALAPPDATA on a Linux host, where
/// USERPROFILE does not exist either, and demanded a non-temp root anyway: on
/// such a host there is no non-temp location left except the developer's real
/// home directory, which the suite must never write into. So the child is given
/// a profile and a temp directory of its OWN, and the claim is checked against
/// the temp directory the BRIDGE sees: with a profile, the root is the profile's
/// and is not under temp. The no-profile half — temp, and every sweep refusing
/// with nothing written or closed — is pinned in tests/data_root.rs
/// (`with_no_profile_location_every_sweep_refuses_rather_than_write_to_temp`).
#[test]
fn the_fallback_data_root_is_not_a_directory_the_os_cleans() {
    let t = TempDir::new("adv37-fallback");
    let profile = t.0.join("profile");
    let child_tmp = t.0.join("child-tmp");
    std::fs::create_dir_all(&profile).unwrap();
    std::fs::create_dir_all(&child_tmp).unwrap();
    let (p, c) = (profile.to_string_lossy().to_string(), child_tmp.to_string_lossy().to_string());
    let b = Bridge::start_with(&[
        ("SWX_SCRATCH_DIR", ""),
        ("LOCALAPPDATA", ""),
        ("USERPROFILE", p.as_str()),
        ("TMPDIR", c.as_str()),
        ("TMP", c.as_str()),
        ("TEMP", c.as_str()),
    ]);
    let r = b.call("swx_recover_list", json!({}));
    let root = r["recoveryRoot"].as_str().unwrap_or("").to_string();
    assert!(!root.is_empty(), "precondition: the listing names its root: {}", s(&r));

    let tmp = std::fs::canonicalize(&child_tmp).unwrap_or(child_tmp.clone());
    let root_c = std::fs::canonicalize(&root).unwrap_or_else(|_| PathBuf::from(&root));
    assert!(
        !root_c.starts_with(&tmp),
        "with LOCALAPPDATA unset the data root falls back to {root:?}, under the OS temp \
         directory — on Windows %TEMP%, which Storage Sense and Disk Cleanup empty on a \
         schedule. Nothing refuses it and nothing warns. Rare in a user-context process, but when \
         it happens the only copy of unsaved work goes to the one place cleaned by design."
    );
    assert!(
        Path::new(&root).starts_with(&profile),
        "with LOCALAPPDATA unset, the user's profile comes before the temp directory: {root:?}"
    );
}

/// THE MISSING PIN FOR `notCopiedYet`, WRITTEN OUT — mutation MF survived:
/// flipping this entry's `verified` from `null` to `true` breaks nothing in 413
/// tests. The human-facing `howToOpen` still says "NOT COPIED YET … copy it out
/// now", but `verified` is the field a programmatic caller branches on, and
/// `true` here would tell an agent that the one file whose ONLY copy sits in the
/// directory the next update deletes is a verified rescue. The code is correct;
/// this test passes. It exists so that stays true.
///
/// The state is built by hand AFTER the bridge starts (the migration runs at
/// start, and would otherwise copy the file and dissolve the state): a sweep in
/// the data root whose manifest names a file that exists only in the legacy
/// location.
#[test]
fn a_file_not_yet_copied_out_of_the_install_cache_is_never_reported_verified() {
    let lay = Layout::new("adv37-notcopied");
    let local = lay.local.path();
    let b = Bridge::start_with(&[("SWX_SCRATCH_DIR", ""), ("LOCALAPPDATA", local.as_str())]);

    let sweep = "20260917-101500";
    let mut body = vec![0u8; 64 * 1024];
    body[4..8].copy_from_slice(&[0x00, 0x00, 0x00, 0x04]);
    write(&lay.legacy().join("recovery").join(sweep).join("bracket.SLDPRT"), &body);
    write(
        &lay.data().join("recovery").join(sweep).join("manifest.json"),
        json!({
            "manifestVersion": 1,
            "files": [{
                "name": "bracket.SLDPRT", "title": "bracket.SLDPRT", "kind": "part",
                "recoveryBytes": body.len(), "verified": true, "preserved": true, "closed": true,
            }],
        })
        .to_string()
        .as_bytes(),
    );

    let r = b.call("swx_recover_list", json!({}));
    let entry = r["sweeps"]
        .as_array()
        .and_then(|a| a.iter().find(|s| s["location"] == json!("data") && s["sweep"] == json!(sweep)))
        .and_then(|s| s["files"].as_array())
        .and_then(|f| f.iter().find(|e| e["name"] == json!("bracket.SLDPRT")))
        .cloned()
        .unwrap_or(Value::Null);
    assert!(entry["notCopiedYet"] == json!(true), "precondition: the state was built: {}", s(&r));

    assert!(
        entry["verified"] != json!(true),
        "a file whose ONLY copy is in ab's install cache — deleted by the next update — is \
         reported verified:true. howToOpen may still warn, but verified is what a caller \
         branches on. Entry: {}",
        s(&entry)
    );
    assert!(
        entry["verified"].is_null(),
        "notCopiedYet is UNKNOWN, not a verdict either way: {}",
        s(&entry)
    );
}