app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
//! 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(®, a.as_bytes());
let t = SystemTime::now() - Duration::from_secs(7_200);
set_mtime(®, 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(®, b.as_bytes());
set_mtime(®, 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)
);
}