app
SOLIDWORKS Bridge
Public Made by Adomby adom
Launch, model and quit SOLIDWORKS from any Adom container. Nothing is ever lost.
main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220
//! `swx-bridge --com-host` — the child process, from the inside.
//!
//! It reads one JSON request per line on stdin, hands it to a [`Backend`], and
//! writes one JSON response per line on stdout. Nothing but protocol lines ever
//! reaches stdout; diagnostics go to stderr, which the server mirrors.
//!
//! Two backends implement the same command set:
//! * [`super::mock::MockBackend`] — a tiny in-memory fake SOLIDWORKS. Runs
//! everywhere; what the whole test suite exercises.
//! * [`super::com::ComBackend`] — the real thing, late-bound IDispatch against
//! a live `SldWorks.Application` (Windows only).
//!
//! A backend call that panics is caught and answered as a normal `ok:false`
//! error: a panic must never take the COM object (and the user's open
//! documents) down with it.
use serde_json::{json, Value};
use std::io::{BufRead, Write};
/// A command failure the child reports as `{"ok":false,...}`.
#[derive(Debug)]
pub struct HostError {
pub message: String,
pub detail: Option<String>,
/// A stable, branchable code. The live run came back with four failures
/// carrying `errorCode: None` and prose only — a caller cannot branch on
/// "document not open: Part2".
pub code: Option<String>,
/// The HOST gave up on this call — its own inner deadline, not the
/// server's. Carried over the wire so the server's `hung` verdict never has
/// to guess a timeout from the wording of a message.
pub timed_out: bool,
/// WHAT THE FAILING CALL ACTUALLY SAID, structured.
///
/// Live, 2026-09-17: every STEP import began failing with `LoadFile4` raising
/// a COM exception, and the reply the caller saw was
/// *"SOLIDWORKS could not import … (LoadFile4 opened nothing, errors=0;
/// LoadFile2 refused)"* with the detail *"SOLIDWORKS rejected 'LoadFile4'"*.
/// The HRESULT, the EXCEPINFO source and description and the argument index
/// — all of which `variant.rs` had already put on the inner error — were
/// dropped on the floor, because the import kept only `e.message`. A caller
/// cannot diagnose a translator from prose that omits the one number the
/// translator returned. This field carries the structured remains of the
/// inner failure through to the reply.
pub data: Option<serde_json::Value>,
/// A DOCUMENT THIS CALL CREATED BEFORE IT FAILED.
///
/// Live, 2026-09-11: an import whose com host timed out AFTER `LoadFile4`
/// had already succeeded left a document open that neither veto knew about —
/// the server could not close it (not in its registry) and the host refused
/// to (not in its `created` set), so it could only be closed by hand. A
/// backend that made something during a call that then failed still MADE it,
/// and that is the host's own first-hand evidence, not a claim the server
/// asked it to believe.
pub created: Option<Value>,
}
impl HostError {
/// Attach the document this failing call had already created. The dispatcher
/// registers it, so the host owns what it made even when it could not say so.
pub fn with_created(mut self, doc: Value) -> Self {
self.created = Some(doc);
self
}
pub fn msg(message: impl Into<String>) -> Self {
Self { message: message.into(), detail: None, code: None, timed_out: false, created: None, data: None }
}
/// A call the HOST abandoned. One half of `host_app_hung`.
pub fn timeout(message: impl Into<String>) -> Self {
Self { message: message.into(), detail: None, code: None, timed_out: true, created: None, data: None }
}
pub fn detailed(message: impl Into<String>, detail: impl Into<String>) -> Self {
Self { message: message.into(), detail: Some(detail.into()), code: None, timed_out: false, created: None, data: None }
}
/// Attach the structured remains of an inner failure.
pub fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
/// A failure a caller can branch on.
pub fn coded(code: &str, message: impl Into<String>) -> Self {
Self { message: message.into(), detail: None, code: Some(code.to_string()), timed_out: false, created: None, data: None }
}
}
/// Commands that change NOTHING the user can lose: no document data, no
/// geometry, no file on disk. Everything else must pass
/// [`Backend::authorize_command`] before it runs.
///
/// The list is inverted ON PURPOSE. An earlier version authorized a set of
/// remembered mutating commands, and the set was incomplete: `run_script` — the
/// whole modelling DSL — `props_set`, `rebuild_check` and `eval` were all
/// missing, so a forged request over this protocol could sketch and extrude into
/// the user's open document while a forged `save` was correctly vetoed. Listing
/// what is SAFE means a command nobody has classified is refused rather than
/// waved through, and a new command added tomorrow is deny-by-default.
pub const READ_ONLY_COMMANDS: &[&str] = &[
"ping",
"status",
// Has SOLIDWORKS finished STARTING? Polled by the readiness gate before any
// document command (see src/readiness.rs).
"startup_state",
"start",
"docs",
// Read-only ONLY: `open {readOnly:false}` is treated as mutating — see
// `is_mutating`.
"open",
"app_state",
"document_info",
"design_info",
"get_parameters",
"inspect_bodies",
"mass_properties",
"bom",
"interference",
"props_get",
"get_preferences",
"check_recovery",
"cloud_status",
// ── the three exemptions worth naming ───────────────────────────────────
// These are NOT side-effect-free, and a list called "read only" that
// contained them would be a lie. They are exempt because what they change is
// the VIEW, not the work: nothing they touch reaches disk, geometry, or the
// saved state of a document, and every one of them is undone by the user
// clicking somewhere else.
//
// `show` ActivateDoc3 + ViewZoomtofit2 — brings a document
// to the front and reframes the camera. Refusing this
// would make swx_show_* useless on the user's own
// documents, which is the one thing it is for.
// `activate_document` ActivateDoc3 — moves which document is in front.
// Same reasoning; it is also how a caller AVOIDS the
// riskier alternative of guessing at the active doc.
// `interference` three `try_put`s on the assembly's
// InterferenceDetectionManager (UseTransform,
// TreatCoincidenceAsInterference,
// MakeInterferingPartsTransparent). Transient
// settings on a manager object that `Done()` tears
// down on every path, including a panic, via a drop
// guard. Nothing survives the call.
//
// All three still carry `windowEtiquette:"background"` and none of them
// raises SOLIDWORKS over what the user is doing.
"show",
"activate_document",
// These CREATE a document, which is the one write that needs no permission:
// the result is the bridge's own, and the host records it as such.
"new_document",
"import",
// `restore_document_visible` (0.10.6) puts ISldWorks::DocumentVisible
// back to TRUE for one document type — the second
// chance after a hidden creation reported it was
// not restored. It can only ever un-hide.
"restore_document_visible",
// `launch_shortcut` STARTS SOLIDWORKS, by invoking the shortcut the
// 3DEXPERIENCE Platform installed. A fourth
// exemption, and it needs its own justification
// because it runs an external process: starting an
// application CREATES NOTHING and DESTROYS NOTHING —
// there is no document to be foreign, no file to
// clobber, and no work to lose. Requiring the write
// opt-in for it would mean the bridge could not do
// the one thing the user most wants when SOLIDWORKS
// is closed. It is gated instead at the SERVER, where
// swx_start requires a `reason` and records the launch
// in the ledger. Note the asymmetry with `stop`,
// which IS always gated: starting is recoverable by
// closing, and on a Connected edition stopping was —
// until this route existed — not recoverable at all.
"launch_shortcut",
// `launch_visualize` (0.9.19) the same act for SOLIDWORKS Visualize —
// its platform shortcut, a `reason` required at the
// server, ledgered. Starting is recoverable.
"launch_visualize",
// `close_import_temps` CLOSES DOCUMENTS, and is exempt for one reason:
// the only documents it can reach are STEP/IGES
// conversion temporaries the BRIDGE ITSELF caused,
// proven by the launcher's own contiguous path layout
// (`is_import_temp` — a `Temp\swx<pid>\…\IC~~\`
// chain no user document can have). That path IS the
// ownership proof, and a stronger one than the
// registry: it survives the pid change that made 1342
// leaked temps un-ownable by a dead bridge process,
// which is exactly the state the drain exists to
// clear. Requiring the write opt-in would make the
// leak undrainable in the DEFAULT posture — the bug
// this verb was written to fix.
//
// BECAUSE the host's veto does not apply here, the
// path proof is the ONLY guard, which is why it is a
// segment walk and not two substring tests: a loose
// version closed a user's saved file with `CloseDoc`
// (no save prompt). See `is_import_temp`.
"close_import_temps",
// `preserve_and_close` CLOSES EVERY OPEN DOCUMENT, including the user's,
// and is on this list deliberately. Drew's standing
// authorization of 2026-09-11 — "you MUST find/build a
// way to safely close the docs/program" — makes the
// CLOSE authorized. It does not make losing anything
// authorized, and those are different permissions, so
// the guard that replaces the opt-in is a SEQUENCE
// rather than a question: every document is saved to
// the recovery folder and verified on disk BEFORE
// anything closes, and a document that could not be
// saved is not closed. The user's own files are never
// written over — a dirty document with a path is saved
// into recovery, not over its original.
"preserve_and_close",
// `release_com` / `com_refs` Drop or count the IDispatch pointers THIS
// HOST holds. They change nothing in SOLIDWORKS — a
// client releasing its own reference is not a write —
// and the quit ladder's central invariant is that no
// rung runs while we hold one, so they must be
// callable without an opt-in.
"release_com",
"com_refs",
// `render_image` (0.9.18) changes the VIEW and a handful of display
// preferences (orientation, display style, RealView,
// shadows, ambient occlusion, perspective, viewport
// background) — every one captured before, restored
// after on every path and read back — and writes a
// NEW image file the server chose. It never saves or
// edits the document. Same reasoning as `show`.
"render_image",
];
/// Does this command need authorization? Anything not known to be side-effect
/// free — plus the one command whose SAFETY DEPENDS ON ITS ARGUMENTS.
///
/// `open` is on the safe list because opening a document read-only changes
/// nothing. `open {readOnly:false}` is a different act: it takes a WRITE LOCK on
/// the user's file, and an older-version document is converted and dirtied in
/// memory, so SOLIDWORKS offers to save it on close. Round 7 changed the
/// default and left the harmful mode caller-settable on the safe list, where
/// `authorize_command` never runs.
pub fn is_mutating(cmd: &str, args: &Value) -> bool {
if cmd == "open" {
return !args.get("readOnly").and_then(|v| v.as_bool()).unwrap_or(true);
}
!READ_ONLY_COMMANDS.contains(&cmd)
}
/// What a com-host backend must do. Deliberately narrow: one string command,
/// one JSON args blob, one JSON result — the same shape the wire speaks.
pub trait Backend {
/// Commands this backend answers (used for the unknown-command hint).
fn commands(&self) -> &'static [&'static str];
fn call(&mut self, cmd: &str, args: &Value) -> Result<Value, HostError>;
/// THE HOST'S OWN VETO, applied by the dispatch loop to every mutating
/// command before it runs.
///
/// This is deliberately part of the trait rather than a call inside each
/// command: a command that forgets to authorize itself cannot exist, because
/// nothing reaches a backend method without passing through here first.
fn authorize_command(&mut self, cmd: &str, args: &Value) -> Result<(), HostError>;
/// Record a document THIS HOST brought into existence, from the `{title,
/// path}` shape every doc-returning result carries.
///
/// Called by the dispatch loop, not by the commands, and that is the whole
/// point. `cmd_import` has THREE success paths (LoadFile4, a handle
/// recovered from ActiveDoc, and the LoadFile2 fallback) and only the first
/// of them registered — so on the live machine the host's independent veto
/// refused to close the very document the bridge had just imported. Per-verb
/// registration means the next verb, or the next branch inside one, can
/// forget. Registering from the RESULT cannot.
fn register_created(&mut self, doc: &Value);
}
/// Result keys that mean "this command brought a document into existence".
///
/// `opened` is deliberately absent: opening the USER'S file does not make it
/// ours, and treating it as a creation would hand the bridge ownership of
/// every document it ever looked at.
pub const CREATED_KEYS: &[&str] = &["created", "imported"];
/// Above this many open documents, an enumeration is not attempted — the count
/// is reported with a sample instead.
///
/// A live session was found holding 1342 open documents (8 GB), all leaked
/// STEP-import temporaries, and `swx_docs` blocked the full 30s marshalling
/// them and got the com host killed. Enumerating N documents is N handles times
/// several COM round-trips each; past a few hundred that cannot finish in any
/// budget. `GetDocumentCount` is ONE call and always answers, so the honest
/// report is "N open, here are the first few" — never a timeout, never a
/// fabricated empty list. See docs/IMPORT-LEAK.md.
pub const DOC_ENUM_CAP_DEFAULT: usize = 200;
/// The cap, overridable by `SWX_DOC_ENUM_CAP` for tests only (so the degrade
/// and drain paths can be exercised with a handful of documents rather than the
/// 200+ it would otherwise take to reach them). Never set in production.
pub fn doc_enum_cap() -> usize {
std::env::var("SWX_DOC_ENUM_CAP")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DOC_ENUM_CAP_DEFAULT)
}
/// How many titles/paths to sample when the count is over the cap.
pub const DOC_SAMPLE: usize = 10;
/// How long the SAMPLE WALK may take. A time box, not a doc count, because the
/// thing that went wrong live was cost per document under memory pressure: the
/// walk stops and says `sampleTruncatedByTime:true` rather than becoming the
/// 30-second wedge the cap exists to prevent.
pub const DOC_SAMPLE_BUDGET_SECS: u64 = 5;
/// How long the DRAIN may spend walking to find its batch. The drain is a bounded
/// batch by design (`max`), and this bounds the SEARCH for that batch too — on a
/// session where most documents are temps it finds them immediately, and on one
/// where they are rare it stops and reports what it scanned.
pub const DRAIN_WALK_BUDGET_SECS: u64 = 20;
/// The smallest a RECOVERY FILE may be and still count as written.
///
/// 0.7.1 used 1 KB, and the comment above the check said why that was wrong: if
/// the smallest real SOLIDWORKS document is TENS of kilobytes, a 1 KB floor does
/// not separate a written file from a TRUNCATED one — only from an empty one. A
/// partial write on a full disk does not usually stop at zero bytes; it stops
/// somewhere in the middle, at 2 KB or 8 KB or 30 KB, and every one of those
/// passed a 1 KB floor and authorised a close.
///
/// 16 KB is MEASURED to be safe in the direction that matters. Across the 166
/// real documents in this container (see `SWDOC_FORMAT_MARKER`) the smallest of
/// any kind is a 29,130-byte part; the smallest assembly is 95,492 and the
/// smallest drawing 319,803. The floor must never reject a real document, so it
/// sits below the smallest one observed — with room for an empty new part, which
/// is smaller than anything in a repository of finished work.
///
/// This is the floor for a PART, and the weakest of the three: an assembly and a
/// drawing are held to more (`DocKind::min_recovery_bytes`), because the smallest
/// real one of each measured here is 95,492 and 319,803 bytes. It is the value a
/// file of unrecognised kind is held to, since it is the one that cannot reject a
/// genuine document of any kind.
///
/// It is deliberately not the whole check. The decisive test is structural — see
/// `recovery_file_verdict` — because a fragment can be any size.
pub const MIN_RECOVERY_BYTES: u64 = 24 * 1024;
/// The OLE Compound File (CFBF) magic. **Legacy SOLIDWORKS only.**
///
/// Pass 23 proposed this as THE structural check: "SOLIDWORKS documents are OLE
/// compound files, so a real one begins with D0 CF 11 E0 A1 B1 1A E1". That was
/// true of older SOLIDWORKS and is the answer the whole internet still gives, and
/// it is WRONG for the files this bridge will actually be handed — see
/// `SWDOC_FORMAT_MARKER`. It is kept because an older document opened from an
/// archive genuinely is a compound file, and accepting one costs nothing.
pub const OLE_COMPOUND_FILE_SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
/// Bytes 4..8 of a modern SOLIDWORKS document. **Measured, not assumed.**
///
/// 2026-09-11, on 166 real `.SLDPRT` / `.SLDASM` / `.SLDDRW` files from three
/// independent hardware repositories in this container (`cosmiic-pg4-hardware`,
/// `cosmiic-bp2-hardware`, `cosmiic-pm-hardware`):
///
/// * **0 of 166** begin with the OLE compound-document signature. It does not
/// appear anywhere in the first 4 MB of any of them. Modern SOLIDWORKS does
/// not use CFBF.
/// * **166 of 166** carry `00 00 00 04` at offset 4. Bytes 0..4 differ per
/// file (they look like a per-document identifier); bytes 4..8 do not vary
/// across parts, assemblies, drawings, repositories or file sizes from 29 KB
/// to 37 MB.
///
/// Had the CFBF check shipped as specified, `recovery_file_verdict` would have
/// rejected EVERY document on a modern install — failing closed, so nothing would
/// have been lost, but the sweep would have preserved everything and closed
/// nothing, for ever. The verb Drew asked for would have been inert.
pub const SWDOC_FORMAT_MARKER: [u8; 4] = [0x00, 0x00, 0x00, 0x04];
/// Which container a recovery file turned out to be, if this bridge knows it.
pub fn recognised_container(head: &[u8]) -> Option<&'static str> {
if head.len() >= 8 && head[..8] == OLE_COMPOUND_FILE_SIGNATURE {
return Some("ole-compound");
}
if head.len() >= 8 && head[4..8] == SWDOC_FORMAT_MARKER {
return Some("swdoc");
}
None
}
/// What a recovery file is, as far as this bridge can tell from the outside.
#[derive(Debug, Clone)]
pub struct RecoveryVerdict {
/// Size on disk. `0` also covers "could not be measured" — see `why`.
pub bytes: u64,
/// Whether the head of the file identifies a container this bridge knows.
pub signature_ok: bool,
/// Which one, when it does: `"swdoc"` or `"ole-compound"`.
pub container: Option<&'static str>,
/// True only when this file may be closed against.
pub ok: bool,
/// Why not, in terms of the harm, when `ok` is false.
pub why: Option<String>,
}
/// IS THIS FILE REALLY THE DOCUMENT? Asked before the document is closed, which
/// is the last moment anybody can ask.
///
/// It **fails closed** on purpose. Anything this cannot read, cannot measure, or
/// does not recognise is NOT preserved, and the document stays open. If a future
/// SOLIDWORKS changed container format, this check would start refusing to close
/// documents — visibly, with the signature it actually found in the message —
/// rather than closing them against files it cannot vouch for. Refusing to close
/// is a nuisance; closing wrongly loses the user's work, and those are not the
/// same size of mistake.
pub fn recovery_file_verdict(
path: &str,
kind: Option<crate::disk::DocKind>,
original_bytes: Option<u64>,
) -> RecoveryVerdict {
use std::io::Read;
let refuse = |bytes: u64, why: String| RecoveryVerdict {
bytes,
signature_ok: false,
container: None,
ok: false,
why: Some(why),
};
if path.trim().is_empty() {
return refuse(0, "no recovery path to check".into());
}
let bytes = match std::fs::metadata(path) {
Ok(m) => m.len(),
Err(e) => return refuse(0, format!("the recovery file could not be measured: {e}")),
};
let mut head = [0u8; 8];
let read = std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut head).map(|_| ()));
if read.is_err() {
return refuse(
bytes,
format!(
"the first 8 bytes of the recovery file could not be read, so this bridge cannot \
tell whether it is a document at all — it was NOT closed"
),
);
}
let Some(container) = recognised_container(&head) else {
let found = head.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join(" ");
return refuse(
bytes,
format!(
"the recovery file does not begin like a SOLIDWORKS document (found {found}; \
expected 00 00 00 04 at offset 4, or the legacy OLE compound-document signature \
D0 CF 11 E0 A1 B1 1A E1 at offset 0), so it is not something this bridge can \
vouch for — the document was NOT closed"
),
);
};
// PER KIND, because the three kinds are not the same size: the smallest real
// part measured here is 29,130 bytes, the smallest assembly 95,492 and the
// smallest drawing 319,803. One floor for all three would have to be the
// lowest of them, which would let an assembly stop at a third of its size and
// still pass.
//
// THE KIND IS PASSED IN, NOT READ OFF THE NAME. 0.7.4 called
// `from_hint(path)` here, and the path is the one this sweep chose FROM THE
// TYPE — so a single unreadable `GetType` named the file `.SLDPRT` and this
// function then read "part" back out of it. The verification was asking the
// question to its own answer, and a 30 KB truncated assembly cleared the
// 24 KB part floor. A derived value cannot check the thing it was derived
// from.
let floor = match kind {
Some(k) => k.min_recovery_bytes(),
// UNKNOWN KIND TAKES THE HIGHEST FLOOR, and, when the document had a file
// of its own, at least what that file weighed. Refusing to close is the
// safe error: it is loud, it names the file and the number, and nothing
// is lost. Closing against a file nobody could classify is not.
None => crate::disk::DocKind::unknown_kind_floor().max(original_bytes.unwrap_or(0)),
};
if bytes < floor {
return RecoveryVerdict {
bytes,
signature_ok: true,
container: Some(container),
ok: false,
why: Some(match kind {
Some(k) => format!(
"the recovery file holds {bytes} byte(s), below the {floor} this bridge \
requires of a real {k:?} — a truncated write is what a full disk produces, \
so the document was NOT closed"
),
None => format!(
"the recovery file holds {bytes} byte(s) and this bridge could not establish \
what KIND of document it is, so it is held to the strictest floor it has \
({floor}) — an unclassifiable document is not closed against an unchecked \
file"
),
}),
};
}
RecoveryVerdict { bytes, signature_ok: true, container: Some(container), ok: true, why: None }
}
/// The resolved `%TEMP%` this process would use, lowercased with `\` separators
/// and no trailing slash. `None` when the environment does not say.
///
/// The com host runs as the SAME USER as the SOLIDWORKS it is attached to (it is
/// a child of the bridge in that user's interactive session), so its own `%TEMP%`
/// IS the directory the launcher writes conversion temps into. That is what makes
/// this an anchor and not a guess.
/// THE TEMP DIRECTORIES A LEAKED IMPORT TEMP MAY LEGITIMATELY SIT UNDER, in
/// precedence order, deduplicated, lowercased and separator-normalised.
///
/// This is a SET and not a single directory because the drain compares a path
/// SOLIDWORKS reported against a directory THIS process resolved, and the two can
/// legitimately differ:
///
/// * the com host inherits Adom Bridge's environment, and ab may run under a
/// service profile, a redirected `TEMP`, or a different account from the
/// interactive session SOLIDWORKS lives in;
/// * the temps this verb exists for were written by a DIFFERENT, DEAD bridge
/// process (the design deliberately does not check the pid) — and 0.5.3
/// nonetheless assumed that process's `%TEMP%` was ours.
///
/// PRECEDENCE
///
/// 1. `SWX_TEMP_ROOT`, when set, is the WHOLE set. It is the explicit override
/// the suite and the gate use to say "this is the resolved root", and an
/// operator can use it on a live machine once they know which directory
/// SOLIDWORKS is really writing under.
/// 2. Otherwise: this process's `%TEMP%` (`TEMP`, then `TMP`, then `TMPDIR`),
/// AND the interactive user's own `HKCU\Environment\TEMP` expanded. Each is
/// a candidate only if it is readable and non-empty, and a document under
/// EITHER qualifies. Reading the user's registry value is the least invasive
/// way to learn the temp directory of the session SOLIDWORKS is in: its own
/// `GetTempPath` cannot be called from another process, and reading its PEB
/// to get at its environment block is invasive, version-fragile and needs
/// rights this bridge should not want.
///
/// An EMPTY set is not a permissive one: the drain refuses
/// (`temp_root_unknown`), because the resolved directory is the whole location
/// proof and the close it authorises does not prompt.
pub fn temp_roots() -> Vec<String> {
let norm = |v: &str| -> Option<String> {
let v = v.trim();
if v.is_empty() {
return None;
}
let n = v.replace('/', "\\").trim_end_matches('\\').to_ascii_lowercase();
if n.is_empty() { None } else { Some(n) }
};
// MOCK MODE'S SIMULATED %TEMP%. The simulated launcher writes Windows paths
// under `C:\Users\drew\AppData\Local\Temp`, and the real environment here
// says `/tmp` — so without this the anchor would reject the mock's own temps
// and the whole drain would be untested. The explicit override still wins, so
// a test can model a MISMATCH (which is exactly what adversary8 does) or an
// unknown root.
if let Ok(v) = std::env::var("SWX_TEMP_ROOT") {
return norm(&v).into_iter().collect();
}
if crate::is_mock() {
return norm(crate::comhost::mock::MOCK_TEMP_ROOT).into_iter().collect();
}
let mut out: Vec<String> = Vec::new();
let mut push = |v: Option<String>| {
if let Some(v) = v {
if !out.contains(&v) {
out.push(v);
}
}
};
for key in ["TEMP", "TMP", "TMPDIR"] {
if let Ok(v) = std::env::var(key) {
push(norm(&v));
break;
}
}
push(user_registry_temp().and_then(|v| norm(&v)));
out
}
/// The FIRST resolved temp root, for reporting and for the single-root callers.
/// `None` means no root could be resolved at all, which refuses.
pub fn temp_root() -> Option<String> {
temp_roots().into_iter().next()
}
/// `HKCU\Environment\TEMP`, with `%USERPROFILE%`-style references expanded.
///
/// This is the interactive user's own setting — the one SOLIDWORKS inherits when
/// the user launches it — which is precisely the value this process's environment
/// may NOT have. Read-only, one value, no write of any kind.
#[cfg(windows)]
fn user_registry_temp() -> Option<String> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let key = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment").ok()?;
let raw: String = key.get_value("TEMP").or_else(|_| key.get_value("TMP")).ok()?;
Some(expand_env_refs(&raw))
}
#[cfg(not(windows))]
fn user_registry_temp() -> Option<String> {
None
}
/// `%USERPROFILE%\AppData\Local\Temp` -> the expanded path. An unresolvable
/// reference leaves the text alone, which then simply fails to match any path —
/// a miss, never a wildcard.
#[cfg(windows)]
fn expand_env_refs(raw: &str) -> String {
let mut out = String::new();
let mut rest = raw;
while let Some(i) = rest.find('%') {
out.push_str(&rest[..i]);
let after = &rest[i + 1..];
match after.find('%') {
Some(j) => {
let name = &after[..j];
match std::env::var(name) {
Ok(v) => out.push_str(&v),
Err(_) => {
out.push('%');
out.push_str(name);
out.push('%');
}
}
rest = &after[j + 1..];
}
None => {
out.push('%');
out.push_str(after);
rest = "";
}
}
}
out.push_str(rest);
out
}
/// Is this the path of a STEP/IGES-import TEMPORARY — a document SOLIDWORKS
/// created under the bridge's own temp directory while converting an imported
/// file, never saved to a real location and unambiguously transient?
///
/// THREE things must all hold, and each closed a data-loss path:
///
/// 1. **LOCATION.** The path must sit under THIS USER'S RESOLVED `%TEMP%`
/// ([`temp_root`]), and that is the WHOLE location test. Proving the layout
/// alone was not enough: `D:\Temp\swx107688\IC~~\First.step.SLDPRT` has the
/// launcher's exact shape on a volume the launcher never writes to, and the
/// drain closes what qualifies with `CloseDoc`, which does not prompt. An
/// earlier version OR-ed in a shape test (`parent` ends
/// `appdata\local\temp`), which made the anchor advisory: a restored profile
/// backup at `E:\Backups\drew\AppData\Local\Temp\swx…\IC~~\…`, or another
/// user's profile, qualified — and when `%TEMP%` was unreadable the shape
/// alone decided. **No root, no drain**: `temp_root() == None` matches
/// nothing, and the verb refuses with `temp_root_unknown` rather than
/// reporting a silent zero.
/// 2. **THE PID FOLDER.** Immediately under `%TEMP%`, a `swx<digits>` segment
/// (the 8.3 short form `SWX107~1` counts). The pid VALUE is deliberately not
/// checked: the 1342 leaked temps belonged to a DEAD bridge process, and a
/// dead pid's directory is still under the same `%TEMP%`.
/// 3. **`IC~~` DIRECTLY UNDER IT**, then the file. Live evidence is exactly
/// `…\Temp\swx107688\IC~~\First.step.SLDPRT`; anything deeper is not a shape
/// the launcher produces. A miss costs memory, a false match costs data, and
/// those are not the same price.
pub fn is_import_temp(path: &str) -> bool {
is_import_temp_under_any(path, &temp_roots())
}
/// THE LAUNCHER'S TAIL, wherever it lives: `…\swx<digits>\ic~~\<file>`.
///
/// This is deliberately NOT the drain's test — it is the NEAR MISS. A document
/// whose path carries this tail but sits under no resolved temp root is almost
/// certainly a leaked import temp whose `%TEMP%` this process does not know, and
/// the drain has to be able to SAY that: 0.5.3 matched nothing in that case and
/// reported "every open STEP/IGES import temporary was closed" with three of them
/// open. A miss is the safe direction for the close; it is not the safe direction
/// for the report.
pub fn has_import_temp_tail(path: &str) -> bool {
tail_parts(path).is_some()
}
/// `(parent segments, pid segment)` when the path ends `…\swx<digits>\ic~~\<file>`.
/// Lowercased, separators normalised.
fn tail_parts(path: &str) -> Option<(Vec<String>, String)> {
let lower = path.replace('/', "\\").to_ascii_lowercase();
let segs: Vec<&str> = lower.split('\\').filter(|s| !s.is_empty()).collect();
if segs.len() < 4 {
return None;
}
let file_at = segs.len() - 1;
let ic_at = file_at - 1;
let pid_at = ic_at - 1;
if segs[ic_at] != "ic~~" || !is_swx_pid_dir(segs[pid_at]) {
return None;
}
Some((segs[..pid_at].iter().map(|s| s.to_string()).collect(), segs[pid_at].to_string()))
}
/// The same rule with the temp root made explicit, so every branch is testable
/// without mutating the process environment.
pub fn is_import_temp_under(path: &str, temp_root: Option<&str>) -> bool {
let roots: Vec<String> = temp_root.into_iter().map(String::from).collect();
is_import_temp_under_any(path, &roots)
}
/// The rule against a SET of resolved roots: the document qualifies if its pid
/// folder's parent IS one of them. An EMPTY set matches nothing — no root, no
/// drain.
pub fn is_import_temp_under_any(path: &str, roots: &[String]) -> bool {
if roots.is_empty() {
return false;
}
let Some((parent, _pid)) = tail_parts(path) else { return false };
roots.iter().any(|root| {
let root = root.replace('/', "\\").trim_end_matches('\\').to_ascii_lowercase();
let want: Vec<&str> = root.split('\\').filter(|s| !s.is_empty()).collect();
!want.is_empty()
&& parent.len() == want.len()
&& parent.iter().zip(want.iter()).all(|(a, b)| segment_equivalent(a, b))
})
}
/// Two path segments that name the SAME directory: equal, or one is the other's
/// 8.3 short form.
///
/// Both sides have to be resolved, not just compared, because the two forms of
/// the same directory are both legitimate: `GetPathName` can hand back
/// `C:\Users\DREW~1\AppData\Local\Temp\…` for the very directory `%TEMP%`
/// reports as `C:\Users\drew\AppData\Local\Temp`. `GetLongPathName` would be
/// the Windows way to do it and is deliberately NOT used: it answers only for a
/// path that still exists on disk, and this rule is asked about documents whose
/// temp file may already have been deleted while the document is still open in
/// SOLIDWORKS. A rule whose answer depends on whether a file happens to exist is
/// not a rule you can reason about before a close.
fn segment_equivalent(a: &str, b: &str) -> bool {
a == b || is_short_form_of(a, b) || is_short_form_of(b, a)
}
/// Is `short` the 8.3 short form of `long`? Both lowercased.
///
/// Windows builds it from the first six characters of the name with spaces and
/// periods removed, plus `~<n>`. The six-character head is required to be
/// EXACTLY that long (or the whole name, when the name is shorter), so `a~1`
/// cannot stand in for `adversary` — a loose prefix rule would let a short
/// segment match a great many long ones, which is the opposite of an anchor.
fn is_short_form_of(short: &str, long: &str) -> bool {
let Some((head, tail)) = short.split_once('~') else { return false };
if head.is_empty() || tail.is_empty() || !tail.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
let stripped: String = long.chars().filter(|c| *c != ' ' && *c != '.').collect();
// WHEN DOES WINDOWS ACTUALLY GENERATE A TILDE NAME? Two cases, and they are
// different shapes:
//
// 1. MANGLING. The long name does not fit 8.3 — over 8 characters, a space,
// a second period, an extension over 3. The short name is then the first
// SIX characters plus `~n`: `adversary` -> `ADVERS~1`. This is the case
// adversary pass 8 asked for, and it correctly rejects `a~1` standing in
// for `adversary`.
// 2. COLLISION. The long name fits 8.3 perfectly and STILL gets a tilde,
// because another entry in the same directory claims the same short name.
// The short name is then the WHOLE name plus `~n`: `DREW~1` for `drew`.
// This is not hypothetical and it is not rare — it is the standard
// profile-directory case on a domain-joined machine, where `C:\Users\drew`
// sits beside `C:\Users\drew.ADOM` and the short name of the first IS
// `DREW~1`. Pass 8 read this acceptance as a pure widening; it is the
// collision form, and rejecting it would make the drain MISS real leaked
// temps on exactly the machine class this bridge ships to. A miss is the
// safe direction for a close and the wrong direction for a drain that
// exists because 1342 temps went unclosed.
//
// So: the mangled form needs the six-character head, and the collision form
// needs the whole name. Neither admits a loose prefix, which was the actual
// looseness worth removing.
let long_len = long.chars().count();
let needs_mangling = long_len > 8
|| long.contains(' ')
|| long.matches('.').count() > 1
|| long.split('.').nth(1).map(|ext| ext.chars().count() > 3).unwrap_or(false);
let head_len = head.chars().count();
if needs_mangling {
head_len == stripped.chars().count().min(6) && stripped.starts_with(head)
} else {
head == stripped
}
}
/// `swx107688`, or its 8.3 short form `swx107~1`. Lowercased input.
///
/// `swx` followed by DIGITS — not `swx-archive`, not `swxnotes`. The 8.3 form is
/// accepted because a path read through a short-name API legitimately carries it.
fn is_swx_pid_dir(seg: &str) -> bool {
let Some(rest) = seg.strip_prefix("swx") else { return false };
if rest.is_empty() {
return false;
}
match rest.split_once('~') {
Some((head, tail)) => {
!head.is_empty()
&& !tail.is_empty()
&& head.bytes().all(|b| b.is_ascii_digit())
&& tail.bytes().all(|b| b.is_ascii_digit())
}
None => rest.bytes().all(|b| b.is_ascii_digit()),
}
}
/// The document a result says it created, if any.
///
/// IT CAN BE NESTED. `run_script` reports the `new_document` result it ran as
/// its own `created`, so the {title, path} sits one level down — and this
/// function used to stop at the outer object, whose `title` is absent. The
/// effect was that every document the modelling DSL created was invisible to
/// the com host's OWN ownership record while the SERVER's record (which does
/// descend) had it: the two vetoes disagreed, and the host refused to close,
/// save or property-set the very part the bridge had just built
/// ("this document was not created by this bridge"). Live-proven on
/// SOLIDWORKS Connected R2026x while building the NSF machine crate.
pub fn created_document(result: &Value) -> Option<&Value> {
let outer = CREATED_KEYS.iter().find_map(|k| result.get(*k)).filter(|d| d.is_object())?;
Some(match outer.get("created").filter(|c| c.is_object()) {
Some(inner) => inner,
None => outer,
})
}
/// The command set, shared by both backends so they cannot drift apart.
pub const COMMANDS: &[&str] = &[
"ping",
"status",
"start",
"launch_shortcut",
"stop",
"docs",
// Read-only ONLY: `open {readOnly:false}` is treated as mutating — see
// `is_mutating`.
"open",
"close_doc",
"save",
"export",
"props_get",
"props_set",
"mass_properties",
"bom",
"interference",
"rebuild_check",
"pack_and_go",
"eval",
// v0.3 — the Fusion-parity surface.
"new_document",
// 0.10.6 — DocumentVisible put back after a hidden creation.
"restore_document_visible",
// 0.9.3 — has SOLIDWORKS finished starting? (the readiness gate)
"startup_state",
"activate_document",
"close_all_documents",
"document_info",
"design_info",
"get_parameters",
"set_parameter",
"inspect_bodies",
"import",
"close_import_temps",
"preserve_and_close",
// 0.9.18 — swx_render_image.
"render_image",
// 0.9.19 — swx_start {app:"visualize"}: its platform shortcut.
"launch_visualize",
// The quit ladder: one rung per call, plus the two reference-discipline
// commands the ladder's invariant depends on.
"quit_rung",
"release_com",
"com_refs",
"save_as",
"run_script",
"run_macro",
"add_component",
"get_preferences",
"set_preference",
"show",
"check_recovery",
"app_state",
"cloud_status",
];
/// Diagnostics go to stderr RAW: the server mirrors them with the `[swx-com]`
/// tag, so labelling here too would double it — and anything a COM component
/// prints on its own gets the same tag either way.
pub fn log(msg: &str) {
eprintln!("{msg}");
let _ = std::io::stderr().flush();
}
fn send(value: &Value) {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{}", serde_json::to_string(value).unwrap_or_else(|_| "{}".into()));
let _ = out.flush();
}
fn make_backend() -> Box<dyn Backend> {
#[cfg(windows)]
{
if !crate::is_mock() {
return Box::new(super::com::ComBackend::new());
}
}
Box::new(super::mock::MockBackend::new())
}
/// Poll the parent server's liveness; if it is gone, exit — even while a COM
/// call is wedged on the main thread. The main loop only notices a dead parent
/// at stdin EOF, which is BETWEEN requests; a server hard-killed (SIGKILL / end
/// task) mid-verb would otherwise leave this child holding a COM object forever.
/// That is the exact failure the child-process design exists to prevent, so it
/// gets a dedicated watchdog thread rather than relying on EOF.
///
/// KNOWN RESIDUAL — pid reuse. Liveness is keyed on the parent PID, so if the OS
/// recycles that PID onto an unrelated process within the ~1s poll window, this
/// reads the parent as still alive and the child lingers until stdin EOF (or the
/// next COM call returns) catches it instead. The window is small and the
/// fallback is benign (a slightly delayed exit, never a wrong kill), so we
/// accept it rather than pay for handle-identity tracking across the spawn
/// boundary; it cannot be dynamically exercised in this environment anyway.
fn spawn_parent_watchdog() {
let parent: Option<u32> =
std::env::var("SWX_PARENT_PID").ok().and_then(|s| s.parse().ok());
let parent = match parent {
Some(p) if p != 0 => p,
_ => return, // run by hand (no parent to watch) — nothing to do
};
std::thread::Builder::new()
.name("swx-comhost-watchdog".into())
.spawn(move || loop {
std::thread::sleep(std::time::Duration::from_secs(1));
if !parent_alive(parent) {
log(&format!("parent server (pid {parent}) is gone; exiting"));
// Hard exit: a wedged COM call means we cannot unwind cleanly,
// and leaving no orphan is what matters here.
std::process::exit(0);
}
})
.ok();
}
#[cfg(unix)]
fn parent_alive(pid: u32) -> bool {
// /proc entry present AND not a zombie awaiting reap.
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat.rsplit(')').next().map(|s| !s.trim_start().starts_with('Z')).unwrap_or(false),
Err(_) => false,
}
}
#[cfg(windows)]
fn parent_alive(pid: u32) -> bool {
use windows::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows::Win32::System::Threading::{
OpenProcess, WaitForSingleObject, PROCESS_SYNCHRONIZE,
};
unsafe {
// WaitForSingleObject(h, 0), NOT GetExitCodeProcess: a process handle is
// signaled once the process exits, so WAIT_TIMEOUT means "still running"
// and anything else means "exited". This sidesteps the STILL_ACTIVE
// (259) ambiguity entirely — a parent that genuinely exits with code 259
// would be misread as alive by an exit-code check, but its handle is
// still signaled, so the wait reports it correctly.
match OpenProcess(PROCESS_SYNCHRONIZE, false, pid) {
Ok(h) if !h.is_invalid() => {
let alive = WaitForSingleObject(h, 0) == WAIT_TIMEOUT;
let _ = CloseHandle(h);
alive
}
_ => false, // cannot open it (exited/reaped/denied) → treat as gone
}
}
}
/// Child main loop. Never returns normally except on `__exit__` / EOF.
pub fn run() {
let pid = std::process::id();
log(&format!("starting (pid {pid}, mock={})", crate::is_mock()));
spawn_parent_watchdog();
let mut backend = make_backend();
// THE VERSION IS PART OF THE HANDSHAKE. The com host is a separate process
// and can be an OLDER BINARY than the server that spawned it (a half-applied
// update, a stale exe left in a cache dir). A field this server expects and
// that host never sends then reads as `false` — and one of those fields is
// `closeOutcomeUnknown`, where a silently dropped `true` becomes "nothing
// happened". A skew must be an explicit error, not a quietly missing field.
send(&json!({"event": "ready", "pid": pid, "version": crate::BRIDGE_VERSION}));
// Test-only knob: emit junk around every response so the SERVER's
// skip-what-you-cannot-parse path is exercised over the real wire
// (tests/protocol.rs). Never set in production.
let noisy = std::env::var("SWX_MOCK_NOISE").as_deref() == Ok("1");
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
log(&format!("stdin read failed: {e}; exiting"));
break;
}
};
let line = line.trim();
if line.is_empty() {
continue;
}
let req: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
send(&json!({"id": Value::Null, "ok": false, "error": format!("bad request json: {e}")}));
continue;
}
};
let cmd = req.get("cmd").and_then(|v| v.as_str()).unwrap_or("").to_string();
if cmd == "__exit__" {
log("exit requested; shutting down");
break;
}
let id = req.get("id").cloned().unwrap_or(Value::Null);
let args = req.get("args").cloned().unwrap_or_else(|| json!({}));
if noisy {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "this line is not JSON at all");
let _ = writeln!(out, "{}", json!({"event": "noise"}));
let _ = writeln!(out, "{}", json!({"id": 0, "ok": true, "result": {"stale": true}}));
let _ = out.flush();
}
if !backend.commands().contains(&cmd.as_str()) {
send(&json!({
"id": id,
"ok": false,
"error": format!("unknown command: {cmd}"),
"commands": backend.commands(),
}));
continue;
}
// DENY BY DEFAULT. Every mutating command is authorized by the backend
// ITSELF — from its own record of the documents it created and its own
// view of the environment — before it is allowed to run. The server's
// decision never reaches this point as anything but a hint.
if is_mutating(&cmd, &args) {
let verdict = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
backend.authorize_command(&cmd, &args)
}));
match verdict {
Ok(Ok(())) => {}
Ok(Err(e)) => {
send(&json!({"id": id, "ok": false, "error": e.message, "detail": e.detail, "errorCode": e.code, "timedOut": e.timed_out, "errorData": e.data}));
continue;
}
Err(_) => {
send(&json!({
"id": id,
"ok": false,
"error": format!("com host could not authorize '{cmd}' and refuses it"),
"detail": "The authorization check itself panicked. Refusing is the only \
safe answer to a guard that did not complete.",
}));
continue;
}
}
}
// A backend panic (a late-bound COM call meeting something it did not
// expect) becomes an error response, not a dead child.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
backend.call(&cmd, &args)
}));
match result {
Ok(Ok(value)) => {
// STRUCTURAL REGISTRATION. Any command whose result names a
// document it created owns that document from here, whichever
// internal branch produced it.
if let Some(doc) = created_document(&value) {
let doc = doc.clone();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
backend.register_created(&doc)
}));
}
send(&json!({"id": id, "ok": true, "result": value}))
}
Ok(Err(e)) => {
// A CALL THAT FAILED AFTER IT CREATED SOMETHING still created it.
if let Some(doc) = e.created.clone() {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
backend.register_created(&doc)
}));
}
send(
&json!({"id": id, "ok": false, "error": e.message, "detail": e.detail,
"errorCode": e.code, "timedOut": e.timed_out, "errorData": e.data,
"createdBeforeFailing": e.created}),
)
}
Err(panic) => {
let what = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into());
log(&format!("PANIC in '{cmd}': {what}"));
send(&json!({
"id": id,
"ok": false,
"error": format!("com host panicked serving '{cmd}': {what}"),
"detail": "The COM object survived; retry, or report this with the args you used.",
}));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_temp_anchor_is_the_whole_location_test() {
let live = r"C:\Users\drew\AppData\Local\Temp";
// UNDER the resolved root, in every form GetPathName can hand back.
for p in [
r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",
"C:/Users/drew/AppData/Local/Temp/swx107688/IC~~/First.step.SLDPRT",
r"C:\USERS\DREW\APPDATA\LOCAL\TEMP\SWX107~1\IC~~\FIRST.STEP.SLDPRT",
// the 8.3 PROFILE form of the same directory
r"C:\Users\DREW~1\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",
] {
assert!(is_import_temp_under(p, Some(live)), "must still drain: {p}");
}
// NOT under it, however exactly the tail matches: a restored profile
// backup on another volume, and another user's profile. The shape test
// that used to be OR-ed in qualified both.
for p in [
r"E:\Backups\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",
r"C:\Users\someoneelse\AppData\Local\Temp\swx107688\IC~~\x.SLDPRT",
r"D:\Temp\swx107688\IC~~\First.step.SLDPRT",
] {
assert!(!is_import_temp_under(p, Some(live)), "not under the resolved %TEMP%: {p}");
}
// NO ROOT, NO DRAIN — an unreadable %TEMP% matches nothing at all.
assert!(!is_import_temp_under(
r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT",
None
));
}
#[test]
fn a_document_under_either_resolved_root_drains_and_a_near_miss_is_recognisable() {
// TWO candidate roots, because the com host's own %TEMP% and the
// interactive user's HKCU\Environment\TEMP can legitimately differ and a
// leaked temp may sit under either.
let process_root = r"C:\Windows\Temp".to_string();
let user_root = r"C:\Users\drew\AppData\Local\Temp".to_string();
let roots = vec![process_root.clone(), user_root.clone()];
let under_user = r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~\First.step.SLDPRT";
let under_process = r"C:\Windows\Temp\swx4242\IC~~\Second.step.SLDPRT";
let under_neither = r"E:\Backups\drew\AppData\Local\Temp\swx1\IC~~\Third.SLDPRT";
for p in [under_user, under_process] {
assert!(is_import_temp_under_any(p, &roots), "under one of the roots: {p}");
}
assert!(!is_import_temp_under_any(under_neither, &roots));
// ...and with only ONE root known, only that one qualifies.
assert!(is_import_temp_under_any(under_user, &[user_root]));
assert!(!is_import_temp_under_any(under_user, &[process_root]));
// NO ROOT, NO DRAIN — an empty set matches nothing at all.
assert!(!is_import_temp_under_any(under_user, &[]));
// THE NEAR MISS is the launcher's tail regardless of where it sits: it is
// what lets the drain say "3 documents look like temps but are not under
// the directory I compared against" instead of "nothing to do".
for p in [under_user, under_process, under_neither] {
assert!(has_import_temp_tail(p), "the launcher's tail: {p}");
}
// ...and it is not satisfied by the things round five and six closed.
for p in [
r"C:\Users\drew\AppData\Local\Temp\swxnotes\IC~~\keep.sldprt",
r"C:\work\IC~~\drafts\Temp\swx_export\customer-master.sldprt",
r"C:\Users\drew\AppData\Local\Temp\swx107688\IC~~",
] {
assert!(!has_import_temp_tail(p), "not the launcher's tail: {p}");
}
}
#[test]
fn an_8_3_short_name_is_matched_by_the_real_rule_not_a_loose_prefix() {
// The MANGLED form takes the six-character head; the COLLISION form takes
// the whole name (`DREW~1` for `drew`, which is what a domain-joined
// machine really produces when `drew.ADOM` sits beside `drew`). A loose
// prefix — the looseness actually worth removing — is rejected in both.
assert!(segment_equivalent("advers~1", "adversary"), "mangled: six-char head");
assert!(!segment_equivalent("a~1", "adversary"), "a loose prefix is not a short name");
assert!(segment_equivalent("advers~1", "advers"), "collision form of a 6-char name");
assert!(!segment_equivalent("advers~1", "adverse"), "fits 8.3, and the head is not the name");
assert!(segment_equivalent("drew~1", "drew"), "collision form of a name that fits 8.3");
assert!(!segment_equivalent("drew~1", "drewson"), "neither form: 7 chars, head is not it");
assert!(segment_equivalent("myfold~2", "my folder"), "a space forces mangling");
// ...and the symmetry and the malformed cases.
assert!(segment_equivalent("drew", "drew~1"));
assert!(!segment_equivalent("drew~x", "drew"), "the tail must be digits");
assert!(!segment_equivalent("drew", "someone"));
}
#[test]
fn a_created_document_is_found_flat_and_nested() {
let flat = json!({"created": {"title": "Part1", "path": ""}});
assert_eq!(created_document(&flat).unwrap()["title"], "Part1");
let imported = json!({"imported": {"title": "step.sldprt"}});
assert_eq!(created_document(&imported).unwrap()["title"], "step.sldprt");
// run_script's shape: the new_document RESULT is the `created` value.
let nested = json!({
"created": {"created": {"title": "Part2", "path": ""}, "template": "t.prtdot", "type": "part"},
"stepCount": 3
});
assert_eq!(created_document(&nested).unwrap()["title"], "Part2");
// an `opened` document is somebody else's
assert!(created_document(&json!({"opened": {"title": "theirs.sldprt"}})).is_none());
assert!(created_document(&json!({"stepCount": 1})).is_none());
}
}