main
Drew Owens 0.10.6 source e2dca5f 6d ago
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());
    }
}