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.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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357
//! The bridge's DATA — and why it lives in a directory nothing else touches.
//!
//! Everything this bridge writes on its own initiative lands under one root:
//! the recovery folders, the action ledger, the ownership registry, launch
//! state, verb timings, settings, and the scratch files it gives its own
//! documents.
//!
//! ```text
//! %LOCALAPPDATA%\adom-solidworks-bridge\
//! ```
//!
//! A directory ONLY THIS BRIDGE writes to, whose only deleter is a person. That
//! property is the whole design, and it had to be learned twice:
//!
//! **1. SEV-1, MEASURED 2026-09-30 (fixed in 0.9.0).** Until 0.9.0 the root was
//! `%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\` — the directory ab
//! INSTALLS the bridge into. ab's updater re-extracts that whole directory: on
//! Drew's machine it was found CREATED 2026-09-18 09:42 by the auto-install of
//! 0.8.9, and every recovery folder from 09-17, the ledger, the ownership
//! registry and the scratch documents were gone. Only test documents were in
//! them — but the recovery folder is by design the ONLY copy of work the user had
//! not saved.
//!
//! **2. SEV-1, CONDITIONAL (adversary pass 39; fixed in 0.9.1).** 0.9.0 moved the
//! root to `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`: out of the directory ab
//! re-extracts, and INTO the directory ab's own installer owns. What ab's docs
//! say, as CLAIMS: the installer "installs to %LOCALAPPDATA%\Adom Bridge\", the
//! uninstaller "lives at %LOCALAPPDATA%\Adom Bridge\uninstall.exe", and "ab
//! removes its own bridges-cache copy of you". What NOBODY has documented or
//! measured: what an ab uninstall, a repair, or the commonest troubleshooting
//! move there is — "delete the Adom Bridge folder and reinstall" — does to
//! anything else in that directory. The only copy of unsaved work cannot hang on
//! another product's undocumented behaviour; that is the shape of problem 1, one
//! level up. So the root is now a SIBLING of `Adom Bridge\`, named for this
//! bridge alone.
//!
//! Both earlier locations are LEGACY ROOTS (`legacy_roots`, oldest first), and on
//! start `migrate_legacy` copies what they hold into the root: under a temporary
//! name, verified byte for byte, renamed into place, never over an existing file,
//! the originals LEFT where they were, and each file RECORDED by (legacy root,
//! path, size, mtime, sha256). A legacy file that CHANGES after its copy — a
//! rollback appending to the old ledger, rewriting the old ownership registry —
//! is copied AGAIN, beside the first copy (`<name>.legacy-<stamp>`), never over
//! it, and reported `changedSinceCopy`; "changed" means a different size or
//! mtime, or — for a file up to 16 MiB whose size and mtime match — a different
//! sha256, because a restore can put different bytes of the same length back
//! under the same mtime (adversary pass 40). `swx_recover_list` reads the root
//! and every legacy root.
//!
//! TWO VERSIONS OF THE LEDGER OR THE REGISTRY ARE MERGED, NOT PICKED (adversary
//! pass 40). Same-named files in the two legacy roots, or a legacy file that
//! changed after its copy, are two versions of one thing — and for an append-only
//! ledger and an ownership registry both versions are partly right. "The older
//! root keeps the name" handed the live ledger the STALE copy on exactly Drew's
//! timeline (the cache re-created by 0.8.9 after 0.9.0 had copied it and kept
//! appending), and "newest mtime wins" fails the mirror case. So the live
//! `ledger.jsonl` becomes the union of both — a multiset: each distinct line as
//! many times as the version that holds it most, ordered by `at` — and the live `owned-docs.json` the union of both claim lists (restore
//! re-validates every claim against the live session). Both versions are kept
//! beside the merged file, and the merge is recorded in the migration record and
//! the ledger. Any other file keeps the beside rule.
//!
//! `refuse_inside_install` stops a recovery folder ever being placed under the
//! directory the bridge runs from, under ab's own directory, or under a legacy
//! root. And when there is no user profile location at all, the root falls back
//! to the OS temp directory — which Storage Sense and Disk Cleanup empty BY
//! DESIGN — so every sweep then REFUSES (`data_root_is_temp`) rather than put the
//! only copy of unsaved work there, and the start-up log says so.
//!
//! WHAT THE MIGRATION CANNOT DO: a bridge update re-extracts
//! `bridges-cache\solidworks` BEFORE the new bridge first runs, so whatever an
//! older bridge wrote THERE is most likely gone by the time the migration looks.
//! `Adom Bridge\solidworks-data` is outside the bridge cache, so a BRIDGE update
//! should leave 0.9.0's data for 0.9.1 to migrate (inferred from where ab
//! extracts, not measured).
//!
//! Resolution order: `SWX_SCRATCH_DIR` (tests and deliberate overrides — honoured
//! as given), then `%LOCALAPPDATA%\adom-solidworks-bridge`, then
//! `%USERPROFILE%\adom-solidworks-bridge`, and only then the temp directory.
//! `SWX_LEGACY_DIR` (a path list) and `SWX_INSTALL_DIR` override the legacy roots
//! and the install directory for the same reason. `swx_uninstall` removes the
//! bridge's own bookkeeping and `exports/` under this root, and NEVER the
//! recovery folders or the ledger: a recovery file is the user's only copy of
//! work they had not saved, and the ledger is the record of what was done.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
/// The data directory's own name, under `%LOCALAPPDATA%` (or `%USERPROFILE%`).
/// Named for this bridge alone, so that nothing else ever has a reason to write
/// into it or to clean it up.
pub const DATA_DIR_NAME: &str = "adom-solidworks-bridge";
/// Where the root came from. Only `TempFallback` is a refusal: an explicit
/// override is the operator's choice (the test harness points it at a temp
/// directory on purpose), and the two profile locations are the design.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RootSource {
Override,
LocalAppData,
UserProfile,
TempFallback,
}
impl RootSource {
pub fn name(self) -> &'static str {
match self {
RootSource::Override => "SWX_SCRATCH_DIR",
RootSource::LocalAppData => "LOCALAPPDATA",
RootSource::UserProfile => "USERPROFILE",
RootSource::TempFallback => "tempFallback",
}
}
}
fn env_path(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
/// The root, and how it was chosen. Never inside an install location — see the
/// module comment for what that cost, twice.
pub fn root_and_source() -> (PathBuf, RootSource) {
if let Some(dir) = env_path("SWX_SCRATCH_DIR") {
return (PathBuf::from(dir), RootSource::Override);
}
if let Some(local) = env_path("LOCALAPPDATA") {
// A SIBLING of `Adom Bridge\`, never inside it.
return (Path::new(&local).join(DATA_DIR_NAME), RootSource::LocalAppData);
}
// A PROFILE DIRECTORY BEFORE A TEMP ONE. `%TEMP%` is emptied on a schedule by
// design; the user's profile is not.
if let Some(profile) = env_path("USERPROFILE") {
return (Path::new(&profile).join(DATA_DIR_NAME), RootSource::UserProfile);
}
(std::env::temp_dir().join(DATA_DIR_NAME), RootSource::TempFallback)
}
/// Everything the bridge creates lives under here — and nothing else does,
/// which is what makes `swx_uninstall` safe to run blind.
pub fn root() -> PathBuf {
root_and_source().0
}
/// Did the root fall all the way back to the OS temp directory? Then no sweep may
/// write recovery files into it: that is the one standard location cleaned on
/// purpose.
pub fn root_is_temp_fallback() -> bool {
root_and_source().1 == RootSource::TempFallback
}
/// ab's OWN directory — its installer's, its uninstaller's, and the parent of its
/// bridge cache. Nothing of this bridge's may live under it.
pub fn ab_dir() -> Option<PathBuf> {
env_path("LOCALAPPDATA").map(|l| Path::new(&l).join("Adom Bridge"))
}
/// WHERE OLDER BRIDGES KEPT THEIR DATA, OLDEST FIRST: 0.8.x's install cache
/// (`Adom Bridge\bridges-cache\solidworks`), then 0.9.0's
/// `Adom Bridge\solidworks-data`. Read for migration and by `swx_recover_list`;
/// never written. `SWX_LEGACY_DIR` replaces the list (a path list, in order).
pub fn legacy_roots() -> Vec<PathBuf> {
if let Some(list) = env_path("SWX_LEGACY_DIR") {
return std::env::split_paths(&list).filter(|p| !p.as_os_str().is_empty()).collect();
}
match ab_dir() {
Some(ab) => vec![ab.join("bridges-cache").join("solidworks"), ab.join("solidworks-data")],
None => Vec::new(),
}
}
/// The directory the running bridge was INSTALLED into — whatever ab extracted.
/// Anything placed under it is gone at the next update.
pub fn install_dir() -> Option<PathBuf> {
if let Some(dir) = env_path("SWX_INSTALL_DIR") {
return Some(PathBuf::from(dir));
}
std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()))
}
/// A path as the filesystem would resolve it, so that a comparison cannot be
/// talked out of its answer by spelling: `.` and `..` are folded first, then the
/// nearest ancestor that EXISTS is canonicalized (which resolves symlinks,
/// junctions and 8.3 short names) and the not-yet-created remainder is put back.
/// A recovery folder usually does not exist yet when it is checked, which is why
/// canonicalizing the whole path is not enough.
fn resolved(p: &Path) -> PathBuf {
use std::path::Component;
let mut lex = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
lex.pop();
}
other => lex.push(other.as_os_str()),
}
}
let mut base = lex.clone();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
loop {
if let Ok(c) = std::fs::canonicalize(&base) {
let mut out = c;
for t in tail.iter().rev() {
out.push(t);
}
return out;
}
match (base.file_name().map(|f| f.to_os_string()), base.parent()) {
(Some(name), Some(parent)) => {
tail.push(name);
base = parent.to_path_buf();
}
_ => return lex,
}
}
}
/// Is `inner` the same as, or inside, `outer`? Both are RESOLVED first (see
/// `resolved`), then compared case-insensitively with both separators, which is
/// how Windows matches a path. Erring towards "inside" is the safe direction for
/// every caller: it can only refuse a location, never approve one.
pub fn is_within(inner: &Path, outer: &Path) -> bool {
let norm = |p: &Path| {
let s = resolved(p).to_string_lossy().replace('/', "\\").to_ascii_lowercase();
// A canonical Windows path comes back in its verbatim form.
let s = s.strip_prefix("\\\\?\\unc\\").map(|r| format!("\\\\{r}")).unwrap_or(s);
let s = s.strip_prefix("\\\\?\\").map(|r| r.to_string()).unwrap_or(s);
s.trim_end_matches('\\').to_string()
};
let (i, o) = (norm(inner), norm(outer));
!o.is_empty() && (i == o || i.starts_with(&format!("{o}\\")))
}
/// REFUSE A PATH WHOSE LIFECYCLE BELONGS TO SOMETHING ELSE.
///
/// A recovery folder is the user's only copy of work they had not saved. It must
/// never be placed under the directory the bridge is installed in (re-extracted
/// on update), under ab's own directory (its installer and uninstaller own it,
/// and what they do to the rest of it is undocumented), or under a location an
/// older bridge used (both of which are one of those two). The root is outside
/// all of them by construction; this is the check that keeps it that way if an
/// override, a future fallback or an ab change ever points it back inside.
pub fn refuse_inside_install(path: &Path) -> Result<(), String> {
let mut places: Vec<(&'static str, PathBuf)> = Vec::new();
if let Some(d) = install_dir() {
places.push(("the bridge's install directory", d));
}
if let Some(ab) = ab_dir() {
places.push(("ab's own directory", ab));
}
for l in legacy_roots() {
places.push(("a location an older bridge kept its data in", l));
}
for (what, d) in places {
// INSIDE OR EQUAL: a location is re-extracted — or uninstalled — just the
// same as anything under it.
if is_within(path, &d) {
return Err(format!(
"refusing to write {} under {what} ({}): what happens to that directory is \
decided by something other than this bridge. ab re-extracts its bridge cache \
on every update — measured 2026-09-30: the 2026-09-18 auto-install of 0.8.9 \
wiped every recovery folder written the day before — and what ab's uninstaller, \
a repair or a reinstall does to the rest of ab's directory is undocumented. A \
recovery file is the user's only copy of unsaved work, so it goes in a directory \
only this bridge writes to (by default %LOCALAPPDATA%\\{DATA_DIR_NAME}).",
path.display(),
d.display(),
));
}
}
Ok(())
}
/// A second-granular timestamp, `yyyymmdd-hhmmss` (UTC), for names that must sort
/// and must not collide with an earlier copy.
pub fn stamp_now() -> String {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (days, secs) = (now / 86_400, now % 86_400);
let (mut y, mut d) = (1970u64, days);
loop {
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let len = if leap { 366 } else { 365 };
if d < len {
break;
}
d -= len;
y += 1;
}
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let months = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let (mut m, mut day) = (0usize, d);
while m < 12 && day >= months[m] {
day -= months[m];
m += 1;
}
format!(
"{y:04}{:02}{:02}-{:02}{:02}{:02}",
m + 1,
day + 1,
secs / 3600,
(secs % 3600) / 60,
secs % 60
)
}
/// THE PERSISTENT STATE, named. Not a whole legacy directory: the install cache
/// also holds the bridge's own executable and resources, which are not data.
/// Each registry's previous generation (`.bak`, kept by `write_atomic`) goes with
/// it: it is what `owned::restore` falls back to when the live file cannot be
/// read. (`migrated-from-legacy.json` is deliberately absent: every root keeps
/// its own record of what IT copied.)
const MIGRATED_ITEMS: &[&str] = &[
"recovery",
"ledger.jsonl",
"ledger.bak",
"owned-docs.json",
"owned-docs.bak",
"com-host-created.json",
"com-host-created.bak",
"launch-state.json",
"launch-state.bak",
"timings.json",
"timings.bak",
"settings.json",
"settings.bak",
"exports",
];
/// The name a copy takes when its own name is taken — by an earlier copy of the
/// same file that has since CHANGED, or by a different file of the same name from
/// another legacy root. Never an existing name.
fn beside_name(dest: &Path, rel: &Path, stamp: &str) -> PathBuf {
let base = dest.join(rel);
let name = base.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let mut n = 1u32;
loop {
let candidate = base.with_file_name(if n == 1 {
format!("{name}.legacy-{stamp}")
} else {
format!("{name}.legacy-{stamp}-{n}")
});
if !candidate.exists() && !candidate.with_file_name(format!(
"{}.migrating",
candidate.file_name().map(|c| c.to_string_lossy().to_string()).unwrap_or_default()
)).exists()
{
return candidate;
}
n += 1;
}
}
/// ONE COPY, the only way this migration makes one: under a temporary name,
/// verified byte for byte, then renamed into place — so a start that dies
/// mid-copy (a crash, a power cut, ab killing a bridge slow to answer `/status`)
/// never leaves a truncated file under the REAL name, where never-overwrite would
/// protect it forever. Returns the sha256 of what landed. On failure the
/// temporary is removed, and any folder that leaves empty goes too, so the
/// listing never shows an empty rescue beside the real one.
fn copy_verified(from: &Path, to: &Path, dest: &Path) -> Result<String, String> {
if let Some(parent) = to.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = to.with_file_name(format!(
"{}.migrating",
to.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
));
// A leftover from an interrupted start: its original is right here, so it
// is simply replaced.
let _ = std::fs::remove_file(&tmp);
let copied_ok = copy_for_migration(from, &tmp, dest);
// TEST-ONLY FAULT, mock mode only: a copy that lands DIFFERENT from its
// original — what a failing disk, or a sync client rewriting the file
// mid-copy, looks like. Nothing in production creates this file, and outside
// mock mode it is not even looked for.
if copied_ok && crate::is_mock() && dest.join("mock-migrate-corrupts-copy").exists() {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(&tmp) {
let _ = f.write_all(b"!");
}
}
let why = if !copied_ok {
Some("the copy could not be made")
} else if !same_bytes(from, &tmp) {
Some("copy did not verify byte for byte")
} else if to.exists() || std::fs::rename(&tmp, to).is_err() {
Some("the verified copy could not be renamed into place")
} else {
None
};
match why {
None => crate::sha256::hex_of_file(to).ok_or_else(|| "the copy could not be read back".into()),
Some(why) => {
// A COPY THAT DOES NOT MATCH IS NOT A COPY. The original is still in
// the legacy location, untouched, and the next start tries it again.
let _ = std::fs::remove_file(&tmp);
let mut dir = to.parent().map(|p| p.to_path_buf());
while let Some(d) = dir {
if d == dest || !d.starts_with(dest) || std::fs::remove_dir(&d).is_err() {
break;
}
dir = d.parent().map(|p| p.to_path_buf());
}
Err(why.to_string())
}
}
}
/// A file this size or smaller is HASHED on every start when its size and mtime
/// match its record, because a restore can put different bytes of the same
/// length back under the same mtime (adversary pass 40). Larger files are
/// compared by size and mtime only, and their records say so (`comparedBy`).
pub const MIGRATION_HASH_CAP: u64 = 16 * 1024 * 1024;
/// The files whose versions are MERGED rather than picked between. An
/// append-only ledger and an ownership registry are the two files whose
/// versions in two places are both partly right — two histories, two sets of
/// claims — so a rule that keeps one copy under the real name drops the other's
/// half from the live state (adversary pass 40: "the older root keeps the name"
/// gave the live ledger the STALE copy on Drew's timeline).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Merge {
Ledger,
Registry,
}
pub fn merge_kind(rel: &Path) -> Option<Merge> {
match rel.to_str()? {
"ledger.jsonl" => Some(Merge::Ledger),
"owned-docs.json" => Some(Merge::Registry),
_ => None,
}
}
/// THE LEDGER, MERGED — AS A MULTISET (adversary pass 41). A line in BOTH files
/// is one event copied, so it appears once; but two identical lines WITHIN one
/// file are two events (a ledger line before 0.9.3 has no sequence number, `at`
/// is seconds, and `document_closed` from swx_reset_workspace says only
/// {title, by}). So each distinct line appears as many times as the file that
/// holds it MORE times — max(count in live, count in incoming) — never fewer.
/// The result is stable-sorted by `at`; a line with no readable `at` takes the
/// `at` of the line before it in its own file, so it stays beside the entries it
/// was written among. Returns the merged text and its line count.
pub fn merge_ledger_lines(live: &str, incoming: &str) -> (String, usize) {
use std::collections::HashMap;
fn parse(text: &str) -> Vec<(u64, String)> {
let mut last_at = 0u64;
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
let at = serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| v.get("at").and_then(|a| a.as_u64()))
.unwrap_or(last_at);
last_at = at;
out.push((at, line.to_string()));
}
out
}
let mut lines: Vec<(u64, String)> = Vec::new();
let mut live_counts: HashMap<String, usize> = HashMap::new();
for (at, l) in parse(live) {
*live_counts.entry(l.clone()).or_insert(0) += 1;
lines.push((at, l));
}
let mut seen_incoming: HashMap<String, usize> = HashMap::new();
for (at, l) in parse(incoming) {
let c = seen_incoming.entry(l.clone()).or_insert(0);
*c += 1;
if *c > live_counts.get(&l).copied().unwrap_or(0) {
lines.push((at, l));
}
}
lines.sort_by_key(|(at, _)| *at);
let n = lines.len();
let mut out = lines.into_iter().map(|(_, l)| l).collect::<Vec<_>>().join("\n");
if n > 0 {
out.push('\n');
}
(out, n)
}
/// THE REGISTRY, MERGED: every claim in either, each once. A claim is
/// re-validated against the live SOLIDWORKS session when the registry is
/// restored (`owned::restore`), so a union can only ever PROPOSE ownership, never
/// grant it. `None` when either file is not a registry this bridge wrote.
pub fn merge_registry(live: &str, incoming: &str) -> Option<(String, usize)> {
let docs = |t: &str| -> Option<Vec<serde_json::Value>> {
serde_json::from_str::<serde_json::Value>(t).ok()?.get("documents")?.as_array().cloned()
};
let (a, b) = (docs(live)?, docs(incoming)?);
let mut out: Vec<serde_json::Value> = Vec::new();
for d in a.into_iter().chain(b) {
if !out.contains(&d) {
out.push(d);
}
}
let n = out.len();
Some((serde_json::json!({"documents": out}).to_string(), n))
}
/// MERGE a legacy version INTO the live file. Both originals are kept BESIDE the
/// merged file, verified, BEFORE anything is rewritten: the live file as it was,
/// and the incoming version. The merged text holds every line (or claim) of
/// both, so the rewrite loses nothing; it is written to a temporary name and
/// renamed into place — never through `write_atomic`, whose `.bak` would
/// overwrite a `.bak` the migration may have just copied. Returns the record of
/// the merge; on any error nothing has been rewritten.
fn merge_into(
kind: Merge,
from: &Path,
to: &Path,
dest: &Path,
rel: &Path,
stamp: &str,
) -> Result<serde_json::Value, String> {
use serde_json::json;
let live = std::fs::read_to_string(to).map_err(|e| format!("the live file could not be read: {e}"))?;
let incoming =
std::fs::read_to_string(from).map_err(|e| format!("the legacy file could not be read: {e}"))?;
let (text, entries) = match kind {
Merge::Ledger => merge_ledger_lines(&live, &incoming),
Merge::Registry => merge_registry(&live, &incoming).ok_or_else(|| {
"one of the two is not a registry this bridge wrote (JSON with a `documents` list)".to_string()
})?,
};
let rel_of = |p: &Path| p.strip_prefix(dest).unwrap_or(p).to_string_lossy().to_string();
let before = beside_name(dest, rel, stamp);
let before_sha = copy_verified(to, &before, dest)?;
let incoming_copy = beside_name(dest, rel, stamp);
let incoming_sha = copy_verified(from, &incoming_copy, dest)?;
let tmp = to.with_file_name(format!(
"{}.merging",
to.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
));
std::fs::write(&tmp, text.as_bytes())
.and_then(|_| std::fs::rename(&tmp, to))
.map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("the merged file could not be written: {e}")
})?;
Ok(json!({
"kind": match kind { Merge::Ledger => "ledger", Merge::Registry => "registry" },
"into": rel_of(to),
"liveBefore": {"copiedAs": rel_of(&before), "sha256": before_sha, "bytes": live.len()},
"incoming": {"copiedAs": rel_of(&incoming_copy), "sha256": incoming_sha, "bytes": incoming.len()},
"result": {
"bytes": text.len(),
"sha256": crate::sha256::hex_of(text.as_bytes()),
"entries": entries,
},
}))
}
/// COPY WHAT THE LEGACY ROOTS HOLD INTO THE DATA ROOT.
///
/// Legacy roots are taken OLDEST FIRST, files in a fixed order. Every copy goes
/// through `copy_verified`. The originals are LEFT where they were: this runs
/// against directories somebody else is about to overwrite or uninstall, but it
/// is not this code's place to delete anything from them.
///
/// EACH FILE IS RECORDED, by (legacy root, path, size, mtime, sha256 of the
/// copy), in `migrated-from-legacy.json` — cumulative, so a copy the user deletes
/// stays deleted. On a later start a recorded file is compared, not skipped:
///
/// * the same size and mtime → settled, nothing to do;
/// * DIFFERENT → it CHANGED SINCE ITS COPY (0.9.0 settled by name alone, and
/// lost exactly the two files that change by nature: a rollback's appends to
/// the old ledger, a rewritten ownership registry). It is copied AGAIN,
/// beside the first copy as `<name>.legacy-<stamp>` — never over it — and
/// reported under `changedSinceCopy`, in the marker, the ledger and
/// `swx_recover_list`.
///
/// A file NOT recorded yet whose name is already taken in the data root — by a
/// byte-identical file, it is recorded as present; by a DIFFERENT one (a
/// same-named file from the other legacy root), it is copied beside it, never
/// over it and never dropped. A copy that fails is not recorded, so the next
/// start tries it again. Returns the report of THIS run, or `None` when there
/// was nothing new to do.
pub fn migrate_legacy() -> Option<serde_json::Value> {
use serde_json::{json, Value};
let dest = root();
let legacies: Vec<PathBuf> = legacy_roots()
.into_iter()
.filter(|l| l.is_dir() && !is_within(&dest, l) && !is_within(l, &dest))
.collect();
if legacies.is_empty() {
return None;
}
let marker = dest.join("migrated-from-legacy.json");
let previous: Value = std::fs::read_to_string(&marker)
.ok()
.and_then(|t| serde_json::from_str::<Value>(&t).ok())
.unwrap_or(Value::Null);
let list = |key: &str| -> Vec<Value> {
previous.get(key).and_then(|v| v.as_array()).cloned().unwrap_or_default()
};
// THE SETTLED RECORDS, one per (legacy root, file). A record written before
// the root was recorded belongs to the first legacy root — the only one there
// was.
let first_root = legacies[0].to_string_lossy().to_string();
let mut records: Vec<Value> = list("copied")
.into_iter()
.map(|mut r| {
if r.get("outcome").is_none() {
r["outcome"] = json!("copied");
}
r
})
.chain(list("skipped").into_iter().map(|mut r| {
if r.get("outcome").is_none() {
r["outcome"] = json!("present");
}
r
}))
.collect();
let mut changed_history: Vec<Value> = list("changedSinceCopy");
// Bounded: a link that points back up the tree must not recurse forever.
fn walk(src: &Path, rel: &Path, out: &mut Vec<PathBuf>, depth: u32) {
let here = src.join(rel);
if here.is_file() {
out.push(rel.to_path_buf());
} else if depth < 16 {
if let Ok(rd) = std::fs::read_dir(&here) {
for e in rd.flatten() {
walk(src, &rel.join(e.file_name()), out, depth + 1);
}
}
}
}
let mut candidates: Vec<(PathBuf, PathBuf)> = Vec::new();
for legacy in &legacies {
let mut files: Vec<PathBuf> = Vec::new();
for item in MIGRATED_ITEMS {
if legacy.join(item).exists() {
walk(legacy, Path::new(item), &mut files, 0);
}
}
// IN A FIXED ORDER, so what an interrupted start leaves behind does not
// depend on the order a directory listing happened to come back in.
files.sort();
candidates.extend(files.into_iter().map(|f| (legacy.clone(), f)));
}
let stamp = stamp_now();
let now_ms = crate::launchos::epoch_ms() as u64;
let (mut copied, mut skipped, mut failed, mut changed, mut merged) = (
Vec::<Value>::new(),
Vec::<Value>::new(),
Vec::<Value>::new(),
Vec::<Value>::new(),
Vec::<Value>::new(),
);
let mut merged_history: Vec<Value> = list("merged");
let mut merge_failed: Vec<Value> = Vec::new();
let rel_of = |p: &Path| p.strip_prefix(&dest).unwrap_or(p).to_string_lossy().to_string();
let compared_by = |bytes: u64| -> &'static str {
if bytes <= MIGRATION_HASH_CAP {
"size+mtime+sha256"
} else {
"size+mtime (over the 16 MiB hash cap)"
}
};
for (legacy, rel) in candidates {
let from = legacy.join(&rel);
let name = rel.to_string_lossy().to_string();
let lroot = legacy.to_string_lossy().to_string();
let Ok(meta) = std::fs::metadata(&from) else {
failed.push(json!({"legacyRoot": lroot, "file": name, "why": "the original could not be read"}));
continue;
};
let bytes = meta.len();
let mtime = modified_ms(&from.to_string_lossy());
let at = records.iter().position(|r| {
r.get("file").and_then(|f| f.as_str()) == Some(name.as_str())
&& r.get("legacyRoot").and_then(|v| v.as_str()).unwrap_or(&first_root) == lroot
});
let to = dest.join(&rel);
match at {
Some(i) => {
let was_bytes = records[i].get("bytes").and_then(|v| v.as_u64());
let was_mtime = records[i].get("modifiedMs").and_then(|v| v.as_u64());
// A record that measured nothing cannot be compared: it stays
// settled, as the record that wrote it settled it.
let meta_differs = matches!(was_bytes, Some(b) if b != bytes)
|| matches!((was_mtime, mtime), (Some(a), Some(b)) if a != b);
// THE SAME SIZE AND MTIME IS NOT THE SAME FILE (adversary pass 40).
// A restore that puts timestamps back — File History, robocopy
// /COPY:DAT, a sync client — can return different bytes of the
// same length, and equal length is NORMAL for the ownership
// registry (`Part<N>-<13-digit epoch>`). So a small file that
// matches on both is HASHED and compared with the digest
// recorded when it was copied. A file over the cap keeps size and
// mtime, and its record says so (`comparedBy`).
let differs = meta_differs
|| (bytes <= MIGRATION_HASH_CAP && {
let was = records[i]
.get("legacySha256")
.or_else(|| records[i].get("sha256"))
.and_then(|v| v.as_str())
.map(|v| v.to_string());
match (was, crate::sha256::hex_of_file(&from)) {
(Some(w), Some(now)) => w != now,
// Nothing to compare against, or unreadable now: the
// record stays as it was, as before this check.
_ => false,
}
});
if !differs {
continue;
}
let was = json!({
"bytes": was_bytes,
"modifiedMs": was_mtime,
"sha256": records[i].get("legacySha256").or_else(|| records[i].get("sha256")).cloned().unwrap_or(Value::Null),
"copiedAs": records[i].get("copiedAs").cloned().unwrap_or(json!(name)),
});
// CHANGED SINCE ITS COPY. A ledger or a registry is MERGED into
// the live file — both versions' copies kept beside it — so the
// live file holds every history (adversary pass 40); anything else
// is copied again, beside, never over.
let merge = match merge_kind(&rel).filter(|_| to.is_file()) {
Some(kind) => match merge_into(kind, &from, &to, &dest, &rel, &stamp) {
Ok(m) => Some(m),
// A version that cannot be merged is still never dropped:
// it goes beside, below, and the failure is reported.
Err(why) => {
merge_failed.push(json!({
"legacyRoot": lroot, "file": name,
"why": format!("could not be merged, so it was copied beside instead: {why}"),
}));
None
}
},
None => None,
};
let outcome = match merge {
Some(m) => Ok((m, true)),
None => {
let beside = beside_name(&dest, &rel, &stamp);
copy_verified(&from, &beside, &dest)
.map(|sha| (json!({"incoming": {"copiedAs": rel_of(&beside), "sha256": sha}}), false))
}
};
match outcome {
Ok((m, was_merged)) => {
let incoming_as = m["incoming"]["copiedAs"].clone();
let incoming_sha = m["incoming"]["sha256"].clone();
let entry = json!({
"legacyRoot": lroot,
"file": name,
"was": was,
"now": {"bytes": bytes, "modifiedMs": mtime, "sha256": incoming_sha},
"copiedAs": incoming_as,
"mergedInto": if was_merged { json!(name) } else { Value::Null },
"atMs": now_ms,
});
changed.push(entry.clone());
changed_history.push(entry);
if was_merged {
let mut m = m;
m["file"] = json!(name);
m["legacyRoot"] = json!(lroot);
m["why"] = json!("changed since its copy");
m["atMs"] = json!(now_ms);
merged.push(m.clone());
merged_history.push(m);
}
// The record now describes the LATEST copy, so the next
// start compares against what it last saw.
let r = &mut records[i];
r["bytes"] = json!(bytes);
r["modifiedMs"] = json!(mtime);
r["sha256"] = incoming_sha.clone();
r["legacySha256"] = incoming_sha;
r["copiedAs"] = incoming_as;
r["comparedBy"] = json!(compared_by(bytes));
r["outcome"] = json!(if was_merged { "mergedAfterChange" } else { "copiedBesideAfterChange" });
}
Err(why) => failed.push(json!({
"legacyRoot": lroot, "file": name, "why": why, "changedSinceCopy": true,
})),
}
}
None => {
if to.exists() && same_bytes(&from, &to) {
let sha = crate::sha256::hex_of_file(&to);
let entry = json!({
"legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,
"sha256": sha, "legacySha256": sha, "copiedAs": name,
"comparedBy": compared_by(bytes),
"outcome": "present",
"why": "already present in the data root, byte for byte",
});
skipped.push(entry.clone());
records.push(entry);
continue;
}
// SAME NAME, DIFFERENT BYTES — two versions of one file, usually
// from the two legacy roots. A ledger or a registry is MERGED: any
// rule that PICKS one drops a history, and "the older root keeps
// the name" picked the stale one on exactly Drew's timeline
// (adversary pass 40). Anything else is copied beside, never over
// it and never dropped.
if let Some(kind) = merge_kind(&rel).filter(|_| to.is_file()) {
let live_sha_before = crate::sha256::hex_of_file(&to);
match merge_into(kind, &from, &to, &dest, &rel, &stamp) {
Ok(mut m) => {
// The copy that held the real name until now keeps a
// name of its own: if it is still exactly the copy a
// record describes, that record follows it beside.
if let Some(r) = records.iter_mut().find(|r| {
r["copiedAs"] == json!(name)
&& live_sha_before.is_some()
&& r["sha256"].as_str() == live_sha_before.as_deref()
}) {
r["copiedAs"] = m["liveBefore"]["copiedAs"].clone();
}
let entry = json!({
"legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,
"sha256": m["incoming"]["sha256"], "legacySha256": m["incoming"]["sha256"],
"copiedAs": m["incoming"]["copiedAs"],
"comparedBy": compared_by(bytes),
"outcome": "merged",
"why": "a different version of this file was already in the data root; the two were merged, and both are kept beside the merged file",
});
copied.push(entry.clone());
records.push(entry);
m["file"] = json!(name);
m["legacyRoot"] = json!(lroot);
m["why"] = json!("two versions of one file");
m["atMs"] = json!(now_ms);
merged.push(m.clone());
merged_history.push(m);
continue;
}
// A version that cannot be merged (not the shape a merge
// reads) is still never dropped: it goes beside, below.
Err(why) => merge_failed.push(json!({
"legacyRoot": lroot, "file": name,
"why": format!("could not be merged, so it was copied beside instead: {why}"),
})),
}
}
let (target, outcome, why) = if !to.exists() {
(to.clone(), "copied", None)
} else {
(
beside_name(&dest, &rel, &stamp),
"copiedBeside",
Some("a different file of this name was already in the data root; copied beside it, never over it"),
)
};
match copy_verified(&from, &target, &dest) {
Ok(sha) => {
let mut entry = json!({
"legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,
"sha256": sha, "legacySha256": sha, "copiedAs": rel_of(&target),
"comparedBy": compared_by(bytes), "outcome": outcome,
});
if let Some(why) = why {
entry["why"] = json!(why);
}
copied.push(entry.clone());
records.push(entry);
}
Err(why) => failed.push(json!({"legacyRoot": lroot, "file": name, "why": why})),
}
}
}
}
if copied.is_empty() && skipped.is_empty() && failed.is_empty() && changed.is_empty()
&& merged.is_empty()
{
return None;
}
let why = "older bridges kept their data inside ab's directories: 0.8.x in the bridge cache, \
which every update re-extracts (measured 2026-09-30: the 2026-09-18 auto-install \
of 0.8.9 deleted every recovery folder from the day before), and 0.9.0 in \
Adom Bridge\\solidworks-data, which ab's installer and uninstaller own. Data now \
lives in a directory only this bridge writes to.";
let legacy_list: Vec<String> = legacies.iter().map(|l| l.to_string_lossy().to_string()).collect();
let report = json!({
"from": legacy_list,
"to": dest.to_string_lossy(),
"copied": copied,
"failed": failed,
"skipped": skipped,
"changedSinceCopy": changed,
"merged": merged,
"mergeFailed": merge_failed,
"originalsLeftInPlace": true,
"why": why,
});
// THE CUMULATIVE RECORD: everything ever settled, every change ever seen, and
// what is still failing.
let record = json!({
"markerVersion": 2,
"from": legacy_list,
"to": dest.to_string_lossy(),
"copied": records.iter().filter(|r| r["outcome"] != "present").cloned().collect::<Vec<_>>(),
"skipped": records.iter().filter(|r| r["outcome"] == "present").cloned().collect::<Vec<_>>(),
"changedSinceCopy": changed_history,
"merged": merged_history,
"mergeFailed": merge_failed,
"failed": failed,
"originalsLeftInPlace": true,
"hashCapBytes": MIGRATION_HASH_CAP,
"lastRunAtMs": now_ms,
"why": why,
});
if let Ok(text) = serde_json::to_string_pretty(&record) {
let _ = std::fs::create_dir_all(&dest);
let _ = crate::launch::write_atomic(&marker, &text);
}
Some(report)
}
/// The copy itself. Separate so the one test-only fault that models an
/// INTERRUPTED start can sit exactly where an interruption would: mid-copy, into
/// whatever path the migration copies to. In mock mode only, with
/// `mock-migrate-dies-mid-copy` in the data root (its content: how many copies to
/// let through first, default none), it writes the first half of the file and
/// the process exits — nothing in production creates that file, and outside mock
/// mode it is not even looked for.
fn copy_for_migration(from: &Path, to: &Path, dest: &Path) -> bool {
if crate::is_mock() {
if let Ok(spec) = std::fs::read_to_string(dest.join("mock-migrate-dies-mid-copy")) {
use std::sync::atomic::{AtomicUsize, Ordering};
static DONE: AtomicUsize = AtomicUsize::new(0);
let allowed: usize = spec.trim().parse().unwrap_or(0);
if DONE.fetch_add(1, Ordering::SeqCst) >= allowed {
if let Ok(bytes) = std::fs::read(from) {
let _ = std::fs::write(to, &bytes[..bytes.len() / 2]);
}
eprintln!("[swx] mock-migrate-dies-mid-copy: exiting mid-copy of {}", from.display());
std::process::exit(3);
}
}
}
std::fs::copy(from, to).is_ok()
}
/// BYTE FOR BYTE, streamed: a recovery file can be an assembly of hundreds of
/// megabytes, and reading two of them whole to compare them would be the way a
/// migration on a machine short of memory fails. Anything unreadable is "not the
/// same", which is the direction that keeps the original.
pub fn same_bytes(a: &Path, b: &Path) -> bool {
use std::io::Read;
let (Ok(fa), Ok(fb)) = (std::fs::File::open(a), std::fs::File::open(b)) else { return false };
match (fa.metadata(), fb.metadata()) {
(Ok(ma), Ok(mb)) if ma.len() == mb.len() => {}
_ => return false,
}
let (mut ra, mut rb) = (std::io::BufReader::new(fa), std::io::BufReader::new(fb));
let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]);
loop {
let na = match ra.read(&mut ba) {
Ok(n) => n,
Err(_) => return false,
};
if na == 0 {
// The lengths matched, so b must be exhausted too.
return matches!(rb.read(&mut bb[..1]), Ok(0));
}
if rb.read_exact(&mut bb[..na]).is_err() || ba[..na] != bb[..na] {
return false;
}
}
}
/// Where generated files go. Created on demand.
/// Where BRIDGE-CREATED DOCUMENTS are given a file of their own.
///
/// Under `exports/`, so the uninstall wipe already removes it and nothing new
/// has to be remembered about where the bridge writes.
/// A per-process counter for scratch document names, seeded from the clock so
/// two bridge runs in one session cannot collide.
/// Is this path a file the BRIDGE gave one of its own documents, rather than a
/// save the USER asked for?
///
/// The distinction is load-bearing from 0.6.0, because every bridge-created
/// document now has a file. `swx_reset_workspace` refuses to close a scratch
/// document that "has been saved to disk" without `force` — a guard that exists
/// to protect a document the user deliberately put somewhere. A file the bridge
/// wrote into its OWN cache, unasked, is not that; treating it as one would have
/// made the sweep refuse to clean up anything it had ever made.
pub fn is_bridge_scratch_path(path: &str) -> bool {
let want = scratch_docs_dir().to_string_lossy().replace('/', "\\").to_ascii_lowercase();
if want.is_empty() {
return false;
}
let got = path.replace('/', "\\").to_ascii_lowercase();
got.starts_with(&format!("{}\\", want.trim_end_matches('\\')))
}
pub fn next_scratch_seq() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::SeqCst);
let base = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
base.wrapping_mul(1000).wrapping_add(n)
}
pub fn scratch_docs_dir() -> PathBuf {
let d = exports_dir().join("scratch");
let _ = std::fs::create_dir_all(&d);
d
}
pub fn exports_dir() -> PathBuf {
let d = root().join("exports");
let _ = std::fs::create_dir_all(&d);
d
}
/// Remove ONLY what the bridge itself wrote — the `exports/` subtree and its own
/// bookkeeping files — best effort, one item at a time. Never the root itself,
/// never `recovery/`, never the ledger. (Until 0.9.0 the root WAS the
/// `bridges-cache/solidworks` folder ab unzipped the running exe into, which is
/// why this was scoped from the start; since 0.9.0 it is a data directory of its
/// own, and the scoping now protects the user's recovery files.)
///
/// Returns (removed, failed) as display strings for `swx_uninstall`.
pub fn remove_artifacts() -> (Vec<String>, Vec<String>) {
remove_artifacts_under(&root())
}
/// The scoped, best-effort removal with the root made explicit (so it is
/// testable without touching the process environment).
pub fn remove_artifacts_under(root: &Path) -> (Vec<String>, Vec<String>) {
let exports = root.join("exports");
let mut removed = Vec::new();
let mut failed = Vec::new();
// The bridge's own bookkeeping files (v0.3) live at the root next to
// `exports/`, so they are removed by name — never by wiping the root, which
// is ab's cache directory holding the running exe.
// NOTE the absence of `ledger.jsonl`. `swx_uninstall` takes no gate — ab
// calls it on app uninstall — so removing the ledger here would let a caller
// erase the record of what it did by asking the bridge to tidy up. The
// audit log is the one artifact that outlives the bridge; a human removes it
// by deleting the folder, which is theirs to do.
for name in [
"timings.json",
"settings.json",
"com-host-created.json",
// The SERVER's half of the ownership record (bug #5) and the launch
// machine's persisted attempt. Both are the bridge's own bookkeeping;
// neither is an audit log, so both go on uninstall.
"owned-docs.json",
"launch-state.json",
"mock-solidworks-session",
"mock-solidworks-open-docs.json",
] {
let f = root.join(name);
if f.exists() {
match std::fs::remove_file(&f) {
Ok(()) => removed.push(f.to_string_lossy().to_string()),
Err(e) => failed.push(format!("{}: {e}", f.display())),
}
}
}
if !exports.exists() {
return (removed, failed);
}
// Per-item first, so one locked file (an export still being pulled) cannot
// abort the whole cleanup — the SDK's best-effort rule.
if let Ok(entries) = std::fs::read_dir(&exports) {
for e in entries.flatten() {
let p = e.path();
let r = if p.is_dir() {
std::fs::remove_dir_all(&p)
} else {
std::fs::remove_file(&p)
};
match r {
Ok(()) => removed.push(p.to_string_lossy().to_string()),
Err(err) => failed.push(format!("{}: {err}", p.display())),
}
}
}
// Then the now-(hopefully-)empty exports dir, but only if everything under
// it went — never force it over a file we could not remove.
if failed.is_empty() {
match std::fs::remove_dir_all(&exports) {
Ok(()) => removed.push(exports.to_string_lossy().to_string()),
Err(err) => failed.push(format!("{}: {err}", exports.display())),
}
}
(removed, failed)
}
static LAST_PRUNE: Mutex<Option<SystemTime>> = Mutex::new(None);
const MAX_AGE: Duration = Duration::from_secs(48 * 3600);
const MAX_FILES: usize = 300;
const PRUNE_EVERY: Duration = Duration::from_secs(600);
/// Age/count-capped cleanup of the exports dir, rate-limited to once per 10
/// minutes so a burst of exports does not turn into a burst of directory scans.
pub fn prune() {
{
let mut last = LAST_PRUNE.lock().unwrap_or_else(|e| e.into_inner());
let now = SystemTime::now();
if let Some(prev) = *last {
if now.duration_since(prev).unwrap_or(PRUNE_EVERY) < PRUNE_EVERY {
return;
}
}
*last = Some(now);
}
prune_dir(&exports_dir(), SystemTime::now(), MAX_AGE, MAX_FILES);
}
/// The prune, with every input explicit (so it is testable without waiting 48h).
pub fn prune_dir(dir: &Path, now: SystemTime, max_age: Duration, max_files: usize) -> usize {
let mut entries: Vec<(SystemTime, PathBuf)> = Vec::new();
let read = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return 0,
};
for e in read.flatten() {
let path = e.path();
let meta = match e.metadata() {
Ok(m) if m.is_file() => m,
_ => continue,
};
let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
entries.push((mtime, path));
}
let doomed = select_doomed(&mut entries, now, max_age, max_files);
let mut removed = 0;
for p in &doomed {
if std::fs::remove_file(p).is_ok() {
removed += 1;
}
}
removed
}
/// Which files to evict: everything past `max_age`, then oldest-first until the
/// survivors fit `max_files`. Sorting oldest-first is what keeps an in-flight
/// brand-new export from ever being the one evicted.
pub fn select_doomed(
entries: &mut Vec<(SystemTime, PathBuf)>,
now: SystemTime,
max_age: Duration,
max_files: usize,
) -> Vec<PathBuf> {
entries.sort_by(|a, b| a.0.cmp(&b.0));
let too_old = |t: &SystemTime| now.duration_since(*t).map(|age| age > max_age).unwrap_or(false);
let mut doomed: Vec<PathBuf> =
entries.iter().filter(|(t, _)| too_old(t)).map(|(_, p)| p.clone()).collect();
let survivors: Vec<&PathBuf> =
entries.iter().filter(|(t, _)| !too_old(t)).map(|(_, p)| p).collect();
if survivors.len() > max_files {
let overflow = survivors.len() - max_files;
doomed.extend(survivors.into_iter().take(overflow).cloned());
}
doomed
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs_ago: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000 - secs_ago)
}
#[test]
fn evicts_by_age() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let mut e = vec![
(at(3600), PathBuf::from("fresh")),
(at(200_000), PathBuf::from("ancient")),
];
let doomed = select_doomed(&mut e, now, MAX_AGE, MAX_FILES);
assert_eq!(doomed, vec![PathBuf::from("ancient")]);
}
#[test]
fn evicts_oldest_first_over_the_cap() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let mut e: Vec<(SystemTime, PathBuf)> =
(0..5).map(|i| (at(i * 10), PathBuf::from(format!("f{i}")))).collect();
// cap of 2 => the 3 oldest go, newest survive
let doomed = select_doomed(&mut e, now, MAX_AGE, 2);
assert_eq!(doomed.len(), 3);
assert!(doomed.contains(&PathBuf::from("f4")));
assert!(!doomed.contains(&PathBuf::from("f0")));
}
#[test]
fn remove_artifacts_deletes_only_exports_never_the_cache_root() {
let root = std::env::temp_dir().join(format!("swx-uninst-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let exports = root.join("exports");
std::fs::create_dir_all(&exports).unwrap();
std::fs::write(exports.join("a.step"), b"x").unwrap();
std::fs::write(exports.join("b-bom.csv"), b"x").unwrap();
// A sibling standing in for ab's own files in the cache root.
std::fs::write(root.join("bridge.json"), b"{}").unwrap();
let (removed, failed) = remove_artifacts_under(&root);
assert!(failed.is_empty(), "{failed:?}");
assert!(!exports.exists(), "the exports subtree must be gone");
assert!(root.join("bridge.json").exists(), "the cache root's own files must survive");
assert!(root.exists(), "the cache root itself must never be deleted");
assert!(!removed.is_empty());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_merged_ledger_holds_every_line_of_both_once_in_at_order() {
let live = "{\"at\":30,\"action\":\"c\"}\n{\"at\":10,\"action\":\"a\"}\n";
let incoming = "{\"at\":20,\"action\":\"b\"}\r\nnot json, after b\n{\"at\":10,\"action\":\"a\"}\n\n";
let (merged, n) = merge_ledger_lines(live, incoming);
assert_eq!(n, 4, "the shared line once, the blank line dropped: {merged:?}");
let lines: Vec<&str> = merged.lines().collect();
assert_eq!(
lines,
vec![
"{\"at\":10,\"action\":\"a\"}",
"{\"at\":20,\"action\":\"b\"}",
// No `at` of its own: it stays beside the line written before it.
"not json, after b",
"{\"at\":30,\"action\":\"c\"}",
]
);
assert!(merged.ends_with('\n'));
assert_eq!(merge_ledger_lines("", ""), (String::new(), 0));
}
#[test]
fn a_merged_registry_holds_every_claim_of_both_once() {
let a = r#"{"documents":[{"title":"Part1","session":7},{"title":"Part2","session":7}]}"#;
let b = r#"{"documents":[{"title":"Part2","session":7},{"title":"Part9","session":9}]}"#;
let (merged, n) = merge_registry(a, b).expect("both are registries");
assert_eq!(n, 3);
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
let titles: Vec<&str> = v["documents"].as_array().unwrap().iter().map(|d| d["title"].as_str().unwrap()).collect();
assert_eq!(titles, vec!["Part1", "Part2", "Part9"]);
assert!(merge_registry(a, "not json").is_none(), "an unreadable registry is not merged");
assert!(merge_registry(a, r#"{"rows":[]}"#).is_none(), "nor one of another shape");
}
#[test]
fn is_within_matches_by_components_not_by_prefix() {
let t = std::env::temp_dir().join(format!("swx-within-{}", std::process::id()));
let inst = t.join("inst");
std::fs::create_dir_all(&inst).unwrap();
// Inside, and equal.
assert!(is_within(&inst.join("data").join("recovery"), &inst));
assert!(is_within(&inst, &inst));
// A SIBLING SHARING A PREFIX is not inside.
assert!(!is_within(&t.join("inst-data"), &inst));
assert!(!is_within(&t, &inst), "a parent is not inside its child");
// Spelling does not get a path out: `..`, case, the other separator.
assert!(is_within(&t.join("x").join("..").join("inst").join("data"), &inst));
assert!(is_within(&Path::new(&inst.to_string_lossy().to_uppercase()).join("d"), &inst));
assert!(is_within(Path::new(&format!("{}\\data", inst.display())), &inst));
let _ = std::fs::remove_dir_all(&t);
}
#[test]
fn same_bytes_is_byte_for_byte() {
let t = std::env::temp_dir().join(format!("swx-samebytes-{}", std::process::id()));
std::fs::create_dir_all(&t).unwrap();
// Larger than one read buffer, so the loop is exercised past its first
// chunk — and the difference is placed in the SECOND chunk.
let a: Vec<u8> = (0..(3 << 20)).map(|i| (i % 251) as u8).collect();
let mut b = a.clone();
std::fs::write(t.join("a"), &a).unwrap();
std::fs::write(t.join("b"), &b).unwrap();
assert!(same_bytes(&t.join("a"), &t.join("b")));
b[(1 << 20) + 17] ^= 1;
std::fs::write(t.join("b"), &b).unwrap();
assert!(!same_bytes(&t.join("a"), &t.join("b")), "one flipped bit is not the same file");
std::fs::write(t.join("b"), &a[..a.len() - 1]).unwrap();
assert!(!same_bytes(&t.join("a"), &t.join("b")), "a truncated copy is not the same file");
assert!(!same_bytes(&t.join("a"), &t.join("missing")), "a missing copy is not the same file");
let _ = std::fs::remove_dir_all(&t);
}
#[test]
fn prune_dir_actually_removes_files_and_survives_a_missing_dir() {
let dir = std::env::temp_dir().join(format!("swx-prune-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let old = dir.join("old.step");
std::fs::write(&old, b"x").unwrap();
// now = far in the future, so everything is past max_age
let far_future = SystemTime::now() + Duration::from_secs(1_000_000);
assert_eq!(prune_dir(&dir, far_future, MAX_AGE, MAX_FILES), 1);
assert!(!old.exists());
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(prune_dir(&dir, far_future, MAX_AGE, MAX_FILES), 0);
}
}
// ── sticky settings ─────────────────────────────────────────────────────────
/// The bridge's own sticky settings (currently just the driving badge), stored
/// beside its exports so `swx_uninstall`'s scope statement stays true and a
/// preference survives a bridge restart without living in anybody's conversation.
fn settings_path() -> PathBuf {
root().join("settings.json")
}
fn settings() -> serde_json::Map<String, serde_json::Value> {
std::fs::read_to_string(settings_path())
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.as_object().cloned())
.unwrap_or_default()
}
pub fn setting_bool(key: &str) -> Option<bool> {
settings().get(key).and_then(|v| v.as_bool())
}
pub fn set_setting_bool(key: &str, value: bool) {
let mut s = settings();
s.insert(key.to_string(), serde_json::Value::Bool(value));
let path = settings_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = crate::launch::write_atomic(&path, &serde_json::Value::Object(s).to_string());
}
/// A file's modification time in milliseconds since the epoch, or `None` when it
/// cannot be read. Read-only. Used to record the state of a user's own file at
/// the moment a sweep closed its document in place, so a later listing can tell
/// "untouched since" from "something wrote to it since".
pub fn modified_ms(path: &str) -> Option<u64> {
if path.trim().is_empty() {
return None;
}
std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
}