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.1 (a62fe72): the data root moves out of ab's directory to %LOCALAPPDATA%\adom-solidworks-bridge; legacy files that…
192302d
20d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
//! 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)
);
}