main
Drew Owens 0.9.3 (93ef8bb): attached is not ready (readiness gate, host_app_gone, lock files); a declined quit on a never-had-a-do… 8483ed6 20d ago
//! ADVERSARIAL REVIEW OF 0.9.2 — pass forty-one, the merge code only.
//!
//! WHAT CHECKS OUT
//!
//!  * NO PING-PONG. After a merge the record keeps `legacySha256` = the digest of
//!    the INCOMING legacy bytes, not of the merged live file, so an unchanged
//!    legacy file compares equal on the next start; and the record of the copy
//!    that held the real name follows its own bytes beside. Pinned below by
//!    starting twice: one merge, no new side files, and the second start's live
//!    ledger differs from the first only by the second start's own lines.
//!  * THE 0.8.x LINE FORMAT is the 0.9.x one. `at` has been `as_secs()` epoch
//!    seconds since v0.3.0 and serde_json has never had `preserve_order`, so a
//!    copied line is byte-identical to its original and the cross-file dedupe
//!    is exact. Equal `at` from two roots sorts STABLY, live first — deterministic,
//!    though second resolution cannot recover the true interleaving.
//!  * A malformed or `at`-less line takes the `at` of the line before it in its
//!    own file; at the head of a file that is 0 and it sorts first. Harmless.
//!  * REGISTRY UNION is safe for the case asked: two `Part3` claims from two
//!    eras own nothing in a third session — `owns_doc` requires
//!    `d.session == session` before any title or path match, and `reconcile`
//!    drops a pid whose recorded start time differs from the live one. The union
//!    only carries claims forward; 0.9.1's pick already carried 0.8.9's. A claim
//!    whose scratch file is gone is inert: nothing open has that path. The
//!    registry is `{"documents": [...]}` and nothing else, so the union loses no
//!    field.
//!  * `recovery_dir_unwritable` precedes the headroom check and the dialog
//!    census, and neither failure point in `recovery_dir()` creates anything;
//!    the sweep's end-of-run cleanup removes an unused manifest and an empty
//!    folder for any later refusal.
//!  * THE HASH CAP is `<=`, so exactly 16 MiB is hashed and 16 MiB + 1 is not —
//!    as documented and recorded in `comparedBy`; pinned below. A file that grows
//!    past the cap between the metadata read and the hash is hashed AS IT IS NOW
//!    (streamed to EOF): the digest can only disagree with the record, so the
//!    race can produce "changed" and never a false "same". Safe direction.

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

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

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")
    }
    fn env(&self) -> [(&'static str, String); 2] {
        [("SWX_SCRATCH_DIR", String::new()), ("LOCALAPPDATA", self.local.path())]
    }
}

fn start(lay: &Layout) -> Bridge {
    let e = lay.env();
    Bridge::start_with(&[(e[0].0, e[0].1.as_str()), (e[1].0, e[1].1.as_str())])
}

/// A ledger line exactly as `ledger::record_in` writes it: serde_json's default
/// map is sorted, so the keys come out `action`, `at`, `detail`.
fn line(at: u64, action: &str, detail: Value) -> String {
    json!({"at": at, "action": action, "detail": detail}).to_string()
}

/// SEVERITY 2 — THE MERGE COLLAPSES REPEATED REAL EVENTS, IN THE WHOLE LIVE
/// LEDGER.
///
/// `merge_ledger_lines` dedupes through ONE `HashSet` shared across both inputs
/// AND within each. Across inputs that is the point: a line present in both
/// files is one event copied. WITHIN one file it is a loss: two identical lines
/// there are two events. A ledger line is `{"at": <epoch SECONDS>, "action",
/// "detail"}` with no sequence and no id, and several details carry nothing that
/// tells two events apart:
///
///   * `document_closed` from `swx_reset_workspace` records `{"title", "by"}` —
///     no path. Two imports of one STEP are both titled `smoke.SLDPRT` (pass 31
///     measured this), each has its own file since 0.6.0, so the reset closes
///     both, by path, in the same second: two identical lines.
///   * `parameter_set {name, value}` — an agent setting a dimension twice to the
///     same value in a second (a retry, a rebuild loop).
///   * `document_activated {name}`.
///
/// And the set is applied to the LIVE file, not just the legacy one: the first
/// time ANY legacy version has to be merged, every repeated event in the live
/// audit log since install is collapsed to one, permanently. Afterwards the
/// ledger says one document was closed where two were — about exactly the
/// action the ledger exists to record.
///
/// Fix: a multiset merge — each distinct line max(count in live, count in
/// incoming) times — which keeps copied events once and repeated events every
/// time. Going forward, a per-line sequence (or ms + pid) makes lines distinct.
#[test]
fn a_repeated_event_within_one_ledger_survives_the_merge() {
    let closed = line(1_790_900_000, "document_closed", json!({"title": "smoke.SLDPRT", "by": "reset_workspace"}));
    let before = line(1_790_899_000, "document_created", json!({"title": "smoke.SLDPRT"}));
    // THE LIVE LEDGER: two imports of one STEP, both closed in the same second.
    let live = format!("{before}\n{closed}\n{closed}\n");
    // THE LEGACY VERSION: copied before the second close happened.
    let incoming = format!("{before}\n{closed}\n");

    let (merged, n) = merge_ledger_lines(&live, &incoming);
    let closes = merged.lines().filter(|l| *l == closed).count();
    assert!(n >= 2, "precondition: the merge produced a ledger: {merged:?}");
    assert_eq!(
        closes, 2,
        "two documents were closed — the live ledger says so, twice — and the merged ledger \
         records ONE. merge_ledger_lines dedupes through a single set shared within each file, \
         and a ledger line has no sequence: `document_closed` from swx_reset_workspace carries \
         only {{title, by}}, so two same-titled imports closed in one second are byte-identical. \
         The set is applied to the LIVE file too, so the first merge collapses every repeated \
         event since install. Merge as a multiset: max(count in live, count in incoming).\n  \
         merged: {merged:?}"
    );
}

/// SEVERITY 3 — THE UNION IS CUT BACK TO 2,000 LINES BY THE NEXT WRITE ON THE
/// SAME START.
///
/// `LEDGER_MAX_LINES` is 2,000 and `record_in` drains the OLDEST lines past it.
/// The merge writes the full union — up to two caps' worth — and then, on the
/// same start, `server.rs` records `data_migrated_from_install_cache` and
/// `legacy_files_merged`. The first of those trims the live ledger back to
/// 2,000, ordered by `at`, so the oldest history in the union — mostly the older
/// era — leaves the live file at once. The migration record still says
/// `entries: <the union>`, so the record and the file disagree from the moment
/// the bridge is up. Nothing is lost on disk — both versions were copied beside
/// first — but the merge was introduced so that the LIVE ledger holds every
/// history, and past the cap it does not. Either exempt the migration start from
/// the cap, or record in the merge entry how many lines the cap removed and that
/// they are in the side copies.
#[test]
fn a_merged_ledger_is_not_truncated_by_the_cap_on_the_same_start() {
    let lay = Layout::new("adv39-cap");
    let mut a = String::new();
    let mut b = String::new();
    for i in 0..1_500u64 {
        a.push_str(&line(1_780_000_000 + i, "parameter_set", json!({"name": format!("A{i}"), "value": i})));
        a.push('\n');
        b.push_str(&line(1_790_000_000 + i, "parameter_set", json!({"name": format!("B{i}"), "value": i})));
        b.push('\n');
    }
    write(&lay.cache().join("ledger.jsonl"), a.as_bytes());
    write(&lay.v090().join("ledger.jsonl"), b.as_bytes());

    let br = start(&lay);
    let _ = br.call("swx_recover_list", json!({}));
    drop(br);

    let live = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();
    assert!(live.contains("\"B1499\""), "precondition: the merge ran: {} lines", live.lines().count());
    let first_era = live.lines().filter(|l| l.contains("\"name\":\"A")).count();
    assert_eq!(
        first_era,
        1_500,
        "the union of two 1,500-line ledgers was written, and the very next ledger write on the \
         same start cut the live file back to LEDGER_MAX_LINES (2,000), dropping {} of the older \
         era's lines. They survive only in the side copies; the merge record still counts the \
         union. The merge exists so the LIVE ledger holds every history. live now has {} lines.",
        1_500 - first_era,
        live.lines().count()
    );
}

/// PIN — THE MERGE IS IDEMPOTENT. Passes today; the coordinator's target (2).
/// Two versions of the ledger and the registry, two starts: one merge each,
/// recorded once, and no new side files on the second start.
#[test]
fn a_merge_happens_once_and_the_second_start_changes_nothing() {
    let lay = Layout::new("adv39-idem");
    let old = SystemTime::now() - Duration::from_secs(86_400);
    write(&lay.cache().join("ledger.jsonl"), format!("{}\n", line(1_789_000_000, "document_created", json!({"title": "CACHE"}))).as_bytes());
    write(&lay.v090().join("ledger.jsonl"), format!("{}\n", line(1_790_000_000, "document_created", json!({"title": "V090"}))).as_bytes());
    write(&lay.cache().join("owned-docs.json"), br#"{"documents":[{"title":"Part1","path":"C:\\a\\Part1-1.SLDPRT","session":11}]}"#);
    write(&lay.v090().join("owned-docs.json"), br#"{"documents":[{"title":"Part2","path":"C:\\b\\Part2-2.SLDPRT","session":22}]}"#);
    for p in [lay.cache().join("ledger.jsonl"), lay.v090().join("ledger.jsonl"),
              lay.cache().join("owned-docs.json"), lay.v090().join("owned-docs.json")] {
        set_mtime(&p, old);
    }

    let side = |d: &Path| -> usize {
        std::fs::read_dir(d)
            .map(|rd| rd.flatten().filter(|e| e.file_name().to_string_lossy().contains(".legacy-")).count())
            .unwrap_or(0)
    };
    let merges = |l: &str| l.lines().filter(|x| x.contains("\"action\":\"legacy_files_merged\"")).count();

    let b1 = start(&lay);
    let _ = b1.call("swx_recover_list", json!({}));
    drop(b1);
    let ledger1 = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();
    let reg1 = std::fs::read_to_string(lay.data().join("owned-docs.json")).unwrap_or_default();
    let side1 = side(&lay.data());
    assert!(ledger1.contains("CACHE") && ledger1.contains("V090"), "first start merged the ledgers: {ledger1:?}");
    assert!(reg1.contains("Part1") && reg1.contains("Part2"), "first start merged the registries: {reg1:?}");
    assert!(side1 > 0, "precondition: both versions were copied beside");
    // NOT VACUOUS: the count compared below must start above zero, or "equal"
    // would be 0 == 0 — the wrong-reason pass this review keeps catching.
    assert!(merges(&ledger1) >= 1, "precondition: the merge was recorded in the ledger: {ledger1}");

    let b2 = start(&lay);
    let _ = b2.call("swx_recover_list", json!({}));
    drop(b2);
    let ledger2 = std::fs::read_to_string(lay.data().join("ledger.jsonl")).unwrap_or_default();
    let reg2 = std::fs::read_to_string(lay.data().join("owned-docs.json")).unwrap_or_default();

    assert_eq!(merges(&ledger1), merges(&ledger2), "the second start merged again: {ledger2}");
    assert_eq!(side1, side(&lay.data()), "the second start copied more versions beside");
    assert!(ledger2.starts_with(ledger1.trim_end_matches('\n')), "the second start rewrote history rather than appending");
    assert_eq!(reg1, reg2, "the second start rewrote the merged registry");
}

/// PIN — THE HASH CAP AT ITS BOUNDARY. Passes today; the coordinator's target (5).
/// A same-size, same-mtime content change is CAUGHT at exactly 16 MiB (hashed)
/// and, as documented, NOT at 16 MiB + 1 (size+mtime only, `comparedBy` says so).
#[test]
fn the_hash_cap_is_inclusive_at_sixteen_mebibytes() {
    use swx_bridge::scratch::MIGRATION_HASH_CAP;
    let lay = Layout::new("adv39-hashcap");
    let t = SystemTime::now() - Duration::from_secs(7_200);
    let at = |n: u64| lay.cache().join("exports").join(format!("blob-{n}.bin"));
    let sizes = [MIGRATION_HASH_CAP, MIGRATION_HASH_CAP + 1];
    for &n in &sizes {
        write(&at(n), &vec![b'a'; n as usize]);
        set_mtime(&at(n), t);
    }
    {
        let b = start(&lay);
        let _ = b.call("swx_recover_list", json!({}));
    }
    // NOT VACUOUS: both blobs must have been copied on the first start, or the
    // "not changed" verdict for the larger one would hold for a file never seen.
    for &n in &sizes {
        let copied = lay.data().join("exports").join(format!("blob-{n}.bin"));
        assert_eq!(
            std::fs::metadata(&copied).map(|m| m.len()).ok(),
            Some(n),
            "precondition: blob-{n}.bin was migrated on the first start"
        );
    }
    for &n in &sizes {
        let mut v = vec![b'a'; n as usize];
        v[(n / 2) as usize] = b'b'; // same length, different bytes
        write(&at(n), &v);
        set_mtime(&at(n), t);
    }
    {
        let b = start(&lay);
        let _ = b.call("swx_recover_list", json!({}));
    }
    let marker = std::fs::read_to_string(lay.data().join("migrated-from-legacy.json")).unwrap_or_default();
    let m: Value = serde_json::from_str(&marker).unwrap_or(Value::Null);
    let changed: Vec<String> = m["changedSinceCopy"]
        .as_array()
        .map(|a| a.iter().filter_map(|e| e["file"].as_str().map(|s| s.to_string())).collect())
        .unwrap_or_default();
    let name = |n: u64| format!("exports/blob-{n}.bin").replace('/', std::path::MAIN_SEPARATOR_STR);
    assert!(
        changed.iter().any(|f| f.ends_with(&format!("blob-{}.bin", MIGRATION_HASH_CAP))),
        "a same-size same-mtime change to a file of EXACTLY the cap ({} bytes) must be caught by \
         the hash: changed = {changed:?}",
        MIGRATION_HASH_CAP
    );
    assert!(
        !changed.iter().any(|f| f.ends_with(&format!("blob-{}.bin", MIGRATION_HASH_CAP + 1))),
        "one byte over the cap is compared by size+mtime only, as documented: changed = {changed:?}"
    );
    let _ = name;
}