diff --git a/.agent/PROJECT_STATE.md b/.agent/PROJECT_STATE.md index 8487ba8..d64dc2d 100644 --- a/.agent/PROJECT_STATE.md +++ b/.agent/PROJECT_STATE.md @@ -4,14 +4,14 @@ Last updated: 2026-08-11 ## Current phase -The public repository foundation and pre-collector hardening are complete. Snapshot input now has a bounded, version-routed, UTC-validated read path, and Registry startup evidence has explicit view and RunOnce semantics. The next engineering phase is the first real Windows Registry Collector and `snapshot` CLI path after this hardening is merged. +The public repository foundation and pre-collector hardening are complete. The first real product slice is implemented on the `feat/registry-startup-snapshot` branch: the read-only CLI captures Registry Run/RunOnce startup evidence and compares before/after Snapshots. Issue #5 is in final review and CI preparation; v0.1 remains incomplete. ## Implemented components - Durable product, architecture, roadmap, threat-model, and ADR documentation. - Project-scoped Codex agents and three repeated-workflow skills. - Public GitHub repository `XiaojuCH/SystemDiff`, community templates, baseline CI, and dependency update policy. -- A draft v1 domain/schema skeleton, deterministic diff boundary, JSON/terminal reporting boundary, rule interface, Windows collector descriptors, and CLI fixture-diff path. +- A draft v1 domain/schema, deterministic diff boundary, JSON/terminal reporting boundary, rule interface, Registry startup Collector, Snapshot assembler, and CLI capture/diff path. - Root `Cargo.lock` generated with Cargo 1.97.1; CI mirrors the local format, Clippy, test, and CLI fixture smoke commands. - The workspace passes rustfmt, Clippy with warnings denied, all-target workspace tests, and the three documented CLI fixture/status commands on stable `x86_64-pc-windows-msvc` (`rustc 1.97.1`). - The first GitHub CI run passed on Windows and Ubuntu. The active `main` ruleset requires pull requests, resolved review threads, and the `Rust (windows-latest)` and `Rust (ubuntu-latest)` checks; it blocks deletion and non-fast-forward updates while retaining an explicit maintainer bypass. @@ -20,12 +20,19 @@ The public repository foundation and pre-collector hardening are complete. Snaps - The CLI rejects Snapshot files larger than 64 MiB before full decoding, bounds the actual read, and routes `document_type`/`schema_version` before constructing Snapshot v1. - `captured_at` uses standards-based RFC 3339 parsing and accepts only known UTC represented by `Z` or `+00:00`; input evidence remains an unchanged wire string. - Registry startup evidence distinguishes Run from RunOnce, validates structured `!`/`*` semantics against the complete raw value name, and preserves prefixed names in identity. Registry view labels have explicit, process-bitness-independent acquisition semantics. +- `windows.registry.startup` v1 uses `RegOpenKeyExW`, `RegQueryInfoKeyW`, and `RegEnumValueW` with query-only access, RAII-owned keys, explicit x64 Registry32/Registry64 passes, HKCU Shared scopes, bounded mutation retry, scoped diagnostics, strict native-data decoding, and complete-byte SHA-256. +- Registry value names use a tagged lossless UTF-16 representation. Unnamed/default values remain ordinary evidence; invalid UTF-16 never passes through lossy replacement. +- `systemdiff snapshot -o ` emits canonical UTC, unredacted draft-v1 JSON through a 64 MiB capped serializer and create-new output semantics. Existing files are never overwritten. +- SystemDiff capture budgets are 8 MiB native data per Registry value, 32 MiB retained native name-and-value evidence across the Registry startup Collector, and 4,096 values per scope. Over-limit evidence degrades only the affected scope and preserves complete siblings. +- The dual-gated test-only HKCU Run E2E produced exactly one expected Added change and zero Removed changes, then verified exact-data guarded cleanup and deletion of temporary Snapshot files. Default CI and production Rust contain no Registry write path. - The bootstrap foundation has passed real-toolchain validation and independent architecture, security, and maintainability review. ## Known limitations -- No Windows collector calls an operating-system API yet. -- `systemdiff snapshot` is intentionally unavailable; the end-to-end MVP is not complete. +- Only Registry Run/RunOnce collection is implemented. Services, Scheduled Tasks, rules/explanations, sanitization, installation/package delivery, and the desktop app are unavailable; the end-to-end v0.1 MVP is not complete. +- The current minimum is Windows 10 version 1709 or Windows Server 2016 version 1709. ARM64 v1 collects HKCU Shared scopes but explicitly reports HKLM alternate-view coverage as unsupported until those view semantics are represented and tested. +- Windows Registry value lookup is case-insensitive, but Collector v1 identity hashes exact UTF-16 code units because there is no documented durable canonical token for independent cross-platform Snapshot comparison. If enumerated display casing changes, v1 may expose a visible false Removed + Added pair; changing this requires a new Collector version. +- Registry startup Collector v1 emits no raw value-data prefixes. Complete native-byte hashes and typed/decode status support deterministic comparison without duplicating potentially sensitive bytes. - The desktop app is a documented future boundary, not a generated Tauri application. - Redaction metadata exists in the schema, but sanitization is not implemented. - The 64 MiB file boundary does not yet impose separate object-count, string-size, nesting-depth, or per-artifact count limits, and parser fuzzing is not configured. A streaming parser is intentionally absent. @@ -45,6 +52,9 @@ The public repository foundation and pre-collector hardening are complete. Snaps - Unknown cross-version comparisons for the same Collector ID are rejected by default; future explicitly verified compatible version pairs remain possible, but no compatibility framework exists yet. - `RegistryView::Shared` follows Microsoft's shared-key model; `Registry32` and `Registry64` require their explicit WOW64 selectors; `Native` is reserved for a sole view where no WOW alternate logical view exists. - RunOnce `!` means deletion is deferred until after the command runs, `*` means the entry runs in Safe Mode, and undocumented combined/repeated forms are retained without inferred behavior. Complete value names remain identity-bearing evidence. +- Registry startup Collector v1 applies the maintainer-approved 8 MiB/value-data, 32 MiB retained name-and-value evidence/Collector, and 4,096 values/scope product limits; these are not Windows Registry platform limits. +- The production CLI and Collector API remain read-only. The only Registry write is a dual-gated, test-only, HKCU-only E2E harness with refuse-existing and exact-data guarded cleanup behavior. +- Windows 10 version 1709 and Windows Server 2016 version 1709 are the current minimum supported collection platforms. ARM64 HKLM alternate Registry views remain explicitly unsupported in Collector v1 while HKCU Shared collection continues. - v0.1 comparison is limited to before/after snapshots from the same Windows installation and user/principal context. - SystemDiff remains offline-first and read-only; evidence is never executed or remediated. - Apache-2.0 is the repository license. @@ -52,13 +62,12 @@ The public repository foundation and pre-collector hardening are complete. Snaps ## Next milestone -Specify and implement the first real Registry Run/RunOnce Collector and `snapshot` CLI path behind deterministic Windows data-source abstractions, including explicit 32/64-bit view coverage, current-user shared coverage, permission/partial outcomes, fixtures, and non-elevated tests. +Complete independent review and both required GitHub checks for Issue #5, then merge only with separate maintainer authorization. Afterward, define the next focused v0.1 Collector issue without starting Services or Scheduled Tasks inside the Registry PR. ## Major unresolved questions - What genuine, monitored private channel should receive Code of Conduct reports? -- What minimum supported Windows versions and architectures will v0.1 promise? -- Which Registry value types or decode failures warrant including the optional raw prefix rather than only typed evidence plus the full-content hash? +- What explicitly versioned, tested identity upgrade should eventually address Registry value-name casing without hiding raw evidence or coupling Diff to Windows NLS behavior? - What bounded/archive policy should apply to Scheduled Task raw XML before that Collector is implemented? - What minimum supported Rust version will be tested and documented? - Should the first desktop spike confirm React/Vite or compare one smaller frontend alternative before accepting ADR 0003? diff --git a/.agent/plans/registry-startup-snapshot.md b/.agent/plans/registry-startup-snapshot.md new file mode 100644 index 0000000..6a27a47 --- /dev/null +++ b/.agent/plans/registry-startup-snapshot.md @@ -0,0 +1,473 @@ +# Registry startup Collector and Snapshot CLI vertical slice + +Status: In progress +Owner: Codex +Last updated: 2026-08-11 + +## Goal + +Deliver GitHub issue [#5](https://github.com/XiaojuCH/SystemDiff/issues/5): the first real, read-only Windows product path that captures the documented current-user and local-machine Run/RunOnce Registry locations into a deterministic Snapshot and exposes it as `systemdiff snapshot -o `. The resulting before/after files must produce a trustworthy Diff for a real startup-entry change without claiming certainty when a Registry scope was denied, unstable, unsupported, or otherwise incomplete. + +This is a Registry startup vertical slice, not another bootstrap or generic hardening project. Any schema or orchestration work below is included only where the first Collector demonstrates a concrete blocker. + +## User-visible outcome + +On a supported Windows installation, a user can run: + +```text +systemdiff snapshot -o before.json +# install software or perform another controlled change +systemdiff snapshot -o after.json +systemdiff diff before.json after.json +``` + +The Snapshot records real Run/RunOnce evidence visible to the current token. The Diff can show an Added, Removed, or Modified Registry startup entry when coverage supports that conclusion, while permission or concurrent-mutation gaps remain explicit. `collectors` reports the Registry startup Collector as implemented and leaves Services and Scheduled Tasks as planned. + +## Current architecture and context + +- The repository is at public `main` commit `c38f2d423af6e1c0d2519657c9bdda31103beab1`; PR #4 is merged, issue #3 is closed, and the merge commit passed Windows and Ubuntu CI. +- `systemdiff-core` already owns the draft-v1 Snapshot envelope, Collector contract, coverage/status validation, Registry startup artifact, native type code, complete-content hash field, typed decode outcome, bounded raw evidence, Run/RunOnce kind, and structured marker semantics. +- `systemdiff-diff` already validates Snapshot coverage, rejects duplicate identities, sorts deterministically, and produces Inconclusive absence when scope coverage is not complete. No production Diff redesign is required. +- `systemdiff-report::write_json` already renders any serializable value to a stream. It must not open output paths. +- `systemdiff-cli` owns parsing and file I/O but has no `snapshot` subcommand, Snapshot assembly path, clock boundary, or no-overwrite output helper. +- `systemdiff-windows` contains descriptors only. It has no Windows dependency, Registry adapter, platform metadata provider, or implemented Collector. +- The current `Collector` trait is synchronous and sufficient. This work does not add async, plugins, runtime discovery, or a generic dependency-injection framework. + +Read-only planning used the project explorer, windows-researcher, and test-engineer roles. Authoritative references: + +- [Run and RunOnce Registry Keys](https://learn.microsoft.com/en-us/windows/win32/setupapi/run-and-runonce-registry-keys) +- [Registry Keys Affected by WOW64](https://learn.microsoft.com/en-us/windows/win32/winprog64/shared-registry-keys) +- [Accessing an Alternate Registry View](https://learn.microsoft.com/en-us/windows/win32/winprog64/accessing-an-alternate-registry-view) +- [RegOpenKeyExW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regopenkeyexw) +- [RegQueryInfoKeyW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regqueryinfokeyw) +- [RegEnumValueW](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regenumvaluew) +- [RegCloseKey](https://learn.microsoft.com/en-us/windows/win32/api/winreg/nf-winreg-regclosekey) +- [Registry Key Security and Access Rights](https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry-key-security-and-access-rights) +- [Registry Value Types](https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry-value-types) +- [Registry Element Size Limits](https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry-element-size-limits) +- [IsWow64Process2](https://learn.microsoft.com/en-us/windows/win32/api/wow64apiset/nf-wow64apiset-iswow64process2) +- [windows-rs Registry bindings](https://microsoft.github.io/windows-docs-rs/doc/windows/Win32/System/Registry/index.html) + +Dependency investigation on 2026-08-11 found: + +- `windows` 0.62.2: Microsoft-maintained, MIT OR Apache-2.0, Rust 1.82, with narrow Win32 features available. +- `sha2` 0.11.0: RustCrypto-maintained, MIT OR Apache-2.0, Rust 1.85; fixed-size SHA-256 does not require its default `alloc`/OID features. +- `windows-version` 0.1.7: Microsoft-maintained, MIT OR Apache-2.0, Rust 1.74, and narrowly exposes current Windows version/build information. + +The official `windows-registry` safe crate was considered but is not suitable for this Collector boundary. Its current value iterator converts names with `String::from_utf16_lossy` and turns any non-success enumeration result into iteration termination rather than exposing the exact error/retry state. SystemDiff needs lossless names, raw bytes, scoped diagnostics, `ERROR_MORE_DATA`, and concurrent-mutation handling, so the implementation will use narrow lower-level `windows` bindings behind a project-owned safe adapter. + +## Constraints + +- Remain offline-first and read-only. Production code opens Registry keys with query-only rights and never calls a Registry create/set/delete API. +- Do not execute, expand, parse, resolve, hash, sign-check, classify, or remediate commands referenced by startup values. +- Use Unicode Win32 APIs through `windows-rs`; do not shell out to or parse `reg.exe`, PowerShell, WMIC, or localized text. +- The current token is the collection boundary. Do not enumerate other users, load profiles, introduce SID/machine identity, or imply cross-user/cross-machine comparability. +- Every key/view is an independent scope. One denied or unstable scope cannot erase unrelated evidence or abort the Snapshot. +- A missing key is a complete empty scope. An unreadable or unstable key is not absence and must not create a false Removed change. +- Keep unsafe code confined to small `systemdiff-windows` adapters with documented buffer/ownership invariants. Core and CLI remain `forbid(unsafe_code)`. +- Default CI remains non-elevated and never writes Run/RunOnce. Mutation-based E2E is separately gated, disposable, and manually authorized. +- Do not implement Services, Scheduled Tasks, files, signatures, executable hashing, command parsing, risk rules, GUI, telemetry, cloud, or remediation. +- Do not introduce async, a general Windows API mock layer, a universal OS abstraction, a plugin ABI, or a configurable global resource-policy framework. +- Draft v1 may change before v0.1, but the schema change must be explicit, documented, fixture-tested, and limited to blockers exposed by real collection. + +## Windows API and scope strategy + +### Scope/view matrix + +Use these exact paths: + +```text +Software\Microsoft\Windows\CurrentVersion\Run +Software\Microsoft\Windows\CurrentVersion\RunOnce +``` + +On x64 Windows, emit six fixed scopes in this order: + +| Scope ID | Hive | View/access | Kind | +| --- | --- | --- | --- | +| `current_user.shared.run` | HKCU | no WOW selector; `Shared` | Run | +| `current_user.shared.run_once` | HKCU | no WOW selector; `Shared` | RunOnce | +| `local_machine.registry32.run` | HKLM | `KEY_WOW64_32KEY`; `Registry32` | Run | +| `local_machine.registry32.run_once` | HKLM | `KEY_WOW64_32KEY`; `Registry32` | RunOnce | +| `local_machine.registry64.run` | HKLM | `KEY_WOW64_64KEY`; `Registry64` | Run | +| `local_machine.registry64.run_once` | HKLM | `KEY_WOW64_64KEY`; `Registry64` | RunOnce | + +On a sole-view x86 Windows installation, emit four scopes: the two HKCU Shared scopes plus `local_machine.native.run` and `local_machine.native.run_once` opened without a WOW selector and labeled `Native`. Never call both WOW selectors on 32-bit Windows and pretend the duplicated physical view is two observations. + +Use `IsWow64Process2(GetCurrentProcess(), ...)` to obtain the native machine architecture rather than process pointer width. The initial implementation targets Windows 10 version 1709 / Server version 1709 or later, the documented floor for that API. `windows-version` supplies version/build metadata; unsupported/unknown platforms fail honestly rather than silently guessing topology. + +Microsoft documents that `KEY_WOW64_32KEY` can select different 32-bit stores on ARM depending on the calling process architecture. Until SystemDiff defines and tests an ARM build/view contract, an ARM64 or unknown native machine still collects HKCU Shared but marks the four HKLM alternate scopes `Unsupported` with stable scoped diagnostics. It must not mislabel those scopes as x64-compatible evidence. + +### Opening and ownership + +For each target, call: + +```text +RegOpenKeyExW(predefined_root, path, 0, KEY_QUERY_VALUE | selector) +``` + +`RegQueryInfoKeyW` and `RegEnumValueW` require only `KEY_QUERY_VALUE`; do not request `KEY_READ`, `KEY_ALL_ACCESS`, write access, ownership privileges, or SACL access. Current-token HKCU uses `HKEY_CURRENT_USER`; no impersonation is introduced. + +The raw `HKEY` generated by windows-rs is copyable and does not close itself. A private, non-Copy `OwnedRegistryKey` in the Win32 adapter takes ownership only after successful `RegOpenKeyExW` and calls `RegCloseKey` in `Drop`. Predefined root handles are borrowed and never wrapped or closed. Drop does not panic; explicit API failures remain numeric Win32 codes rather than being coerced into HRESULT or localized message parsing. + +### Registry data-source boundary + +Use one narrow Collector-specific source interface, not one trait per API: + +```text +RegistryDataSource + detect_layout() -> RegistryLayout + read_key_once(target, limits) -> KeyReadAttempt +``` + +`KeyReadAttempt` represents Missing, a completed raw attempt, a mutation/anomaly, or a structured source failure. A raw attempt contains before/after key metadata and records with complete UTF-16 name code units, numeric Registry type, and complete native data bytes. Handles and pointers never cross the adapter boundary. Scripted fakes return attempts from a queue for deterministic cross-platform tests. + +## Buffer, allocation, and concurrent-mutation strategy + +For each whole-key attempt: + +1. Open a fresh handle with the exact target selector. +2. Call `RegQueryInfoKeyW` for value count, maximum value-name length, maximum value-data length, and last-write `FILETIME`. +3. Treat all maxima as momentary, bounded allocation hints. Maximum name length is UTF-16 code units excluding NUL, so the initialized name buffer uses checked `max + 1` growth within the Windows value-name limit. Never allocate the reported maximum data length up front: one oversized value must not prevent enumeration of normal siblings. +4. Enumerate indices from zero until `ERROR_NO_MORE_ITEMS`; do not stop only at the initial value count because enumeration is unordered and mutable. +5. Probe each index with `lpData = NULL` and a data-length pointer to obtain the complete name, native type, and required data byte count without allocating the value payload. Reset every capacity variable before every `RegEnumValueW` call. A successful name length excludes the NUL; the required data length includes any stored terminators. +6. If the required data length exceeds the per-value limit, emit no incomplete observation, mark the scope Partial with `registry_value_too_large`, increment the index, and continue. If it fits, allocate only that value's checked size and read the same index again. Accept the record only when the second call succeeds and its complete name/type still match the probe. +7. On `ERROR_MORE_DATA`, discard the undefined data buffer and retry the same index within the per-index limit. Use the returned required data length where provided; for a name shortage, re-query metadata and use bounded geometric growth because Microsoft does not promise that the returned name length is the required capacity. A value that grows beyond the cap is omitted with a scoped diagnostic while later indices remain eligible for collection. +8. Count aggregate native value-name and value-data bytes only for retained complete records. A record that would exceed the Collector budget is omitted and diagnosed without consuming the remaining budget, so smaller later siblings can still be retained. +9. After enumeration, query count and last-write time again. Only an attempt with matching before/after count and `FILETIME`, an enumerated count consistent with the final metadata count, no duplicate exact identity, and no enumeration anomaly is considered stable. Reaching the explicit 4,096-value cap while metadata reports additional values is instead a diagnosed resource-limit `Partial` result and does not trigger pointless retries. +10. Retry the entire scope with a fresh handle at most twice after the initial attempt (three total attempts). Discard earlier attempts rather than mixing their values. +11. If all attempts are unstable, retain only complete records from the final attempt, mark the scope `Partial`, and add `registry_changed_during_scan`. This is a best-effort consistency check, never an atomic-snapshot claim; count/time equality cannot detect every ABA race. Static resource omissions remain Partial but do not force pointless whole-scope retries. + +Initial constants to validate with tests and serialized-size measurements: + +- 8 MiB maximum native data bytes for one Registry value; +- 32 MiB maximum aggregate retained native Registry value-name and value-data evidence for the Collector; +- 4,096 values per scope; +- three per-index buffer-growth attempts before the whole-key attempt is treated as unstable/resource-limited; +- the existing 64 MiB maximum serialized Snapshot input/output. + +All `u32`/`usize` conversions, additions, multiplications, and index increments are checked. Exceeding a limit produces a stable diagnostic and Partial scope; SystemDiff never hashes a truncated value and calls it complete. Tests pair an oversized value with a normal sibling and require the normal evidence to survive. The CLI serializes through a 64 MiB capped writer before creating the destination, so rejection is bounded in memory and an emitted Snapshot is always small enough for its own reader. + +## UTF-16, value names, identity, and decoding + +### Lossless value-name evidence + +Real Win32 enumeration returns UTF-16 code units, while the current `value_name: String` cannot represent an unpaired surrogate without replacement and possible identity collision. Because issue #5 requires the complete original name, this Collector exposes a concrete draft-schema blocker. + +Replace the string field with a minimal tagged `RegistryValueName` wire value: + +```text +decoded { value: String } +invalid_utf16 { utf16le_hex: String } +``` + +Valid names retain their exact original casing/text. Invalid names retain all original UTF-16LE code units as validated lowercase hex; no lossy replacement string is emitted. The default/unnamed value is a valid decoded empty string. Core validation and fixtures enforce exactly one tagged form and bounded valid hex. RunOnce marker classification works from the authoritative UTF-16 units, so an ASCII leading `!` or `*` remains interpretable even if later units are invalid. + +### Canonical identity and ordering + +Add one Registry-startup identity helper, not a generic identity subsystem. Scope already fixes hive/view/key/kind. Collector v1 deliberately performs no Unicode normalization, case mapping, or RunOnce-prefix stripping: `canonical_id` is a domain-separated SHA-256 over the exact authoritative UTF-16 code units, encoded as a little-endian `u32` code-unit count followed by each `u16` in little-endian order, after the ASCII domain bytes `systemdiff.registry-startup.identity.v1` and a zero separator. + +Registry value lookup is case-insensitive, but Microsoft does not specify its Unicode comparator or guarantee the casing returned by `RegEnumValueW`. A build-26100 isolated HKCU experiment found that `RegSetValueExW` with alternate casing updated one existing value while enumeration retained its first casing. `CompareStringOrdinal(TRUE)` supplies authoritative pairwise ordinal case-insensitive comparison, but it cannot independently generate a cross-platform wire token; Microsoft does not guarantee that `LCMapStringEx`, linguistic sort keys, or NLS hashes match Registry lookup, and those alternatives risk hidden false merges. + +Collector v1 therefore keeps exact UTF-16 as a conservative, explicitly limited evidence identity. It avoids merging distinct raw evidence, is independent of Rust/Unicode table versions, and treats valid and invalid UTF-16 uniformly. If Windows ever returns different casing for one logical value, v1 may produce a visible false split; this is a known limitation, not a claim that Registry logical identity is case-sensitive. Changing the algorithm requires a new Collector version with verified compatibility semantics. The `!` or `*` unit always participates. If distinct exact names ever produce the same digest, omit the colliding group, keep unrelated observations, mark the scope Partial, and emit `registry_identity_collision` rather than applying last-write-wins. Tests fix exact vectors for casing, delimiter-like characters, Unicode, invalid units, the unnamed/default value, and `Foo`/`!Foo`/`*Foo`. The empty name is ordinary evidence with a stable identity and `NoDocumentedPrefix` for RunOnce; it is not marker-only corruption. + +Sort targets by the fixed scope table and observations by `ArtifactKey`. Sort coverage by scope ID and diagnostics by `(scope_id, code, stage, native_code)`. Never depend on `RegEnumValueW` order or localized messages. + +### Native data and decoding + +The exact `data[..returned_byte_count]` from a successful enumeration is authoritative. Compute SHA-256 before decoding or terminator checks, including embedded/terminal NUL and zero-length data. + +Decode strictly: + +| Native type | Rule | +| --- | --- | +| `REG_SZ` (1) | even byte length, UTF-16LE, documented termination; one logical string | +| `REG_EXPAND_SZ` (2) | same strict string rules; preserve `%VAR%` unexpanded | +| `REG_DWORD` (4) | exactly 4 little-endian bytes | +| `REG_DWORD_BIG_ENDIAN` (5) | exactly 4 big-endian bytes | +| `REG_MULTI_SZ` (7) | even UTF-16LE bytes; require the documented double-NUL termination, including for an empty list; preserve item order | +| `REG_QWORD` (11) | exactly 8 little-endian bytes | +| known non-decoded types such as `REG_NONE`, `REG_BINARY`, `REG_LINK`, and resource types | `NotApplicable` | +| unknown numeric type | `UnsupportedType` | + +Odd byte counts, invalid UTF-16, malformed termination, or wrong integer length become `InvalidData`; they do not make a readable scope incomplete because the complete bytes were captured for hashing and the native type, complete hash, and decode status remain available. Zero-length binary/none data is valid non-decoded evidence and uses the SHA-256 of empty bytes; zero-length supported string/integer data is InvalidData. + +Collector v1 sets value-data `raw_evidence` to `None` for every decode status. The complete hash and typed/decode status are sufficient for deterministic comparison, while a blanket prefix for binary, unknown, or malformed values would create privacy and JSON-size costs without a concrete current use. The existing bounded wire field remains available for a future explicitly reviewed forensic policy; this issue does not define one. Lossless UTF-16 hex for an invalid value *name* is authoritative identity evidence, not a value-data raw prefix. + +## Coverage, diagnostics, and Snapshot assembly + +### Minimal diagnostic schema change + +The current `Diagnostic` has no machine-readable scope association. Add `scope_id: Option` so a diagnostic can identify one of the six Registry scopes; `None` remains available for Collector-wide layout errors. Snapshot validation rejects a diagnostic that references an unknown scope. This is an explicit pre-v0.1 draft-v1 schema change justified by real per-scope failure semantics; update fixtures and `docs/data-format.md`. Do not introduce a generic error taxonomy. + +Stable initial codes include: + +- `registry_access_denied` +- `registry_open_failed` +- `registry_query_failed` +- `registry_enumeration_failed` +- `registry_changed_during_scan` +- `registry_value_too_large` +- `registry_resource_limit` +- `registry_identity_collision` +- `registry_layout_unsupported` + +Messages are short English operator context only. Machine logic uses code, scope, stage, and numeric Win32 code; diagnostics never include value data, names, commands, paths beyond the fixed documented key, usernames, or localized system error text. + +Status mapping: + +- `ERROR_FILE_NOT_FOUND` from the exact key open: Complete empty scope. +- `ERROR_ACCESS_DENIED`: PermissionDenied plus scoped numeric diagnostic. +- stable enumeration, including values with InvalidData: Complete. +- retained observations after exhausted mutation/resource/item failures: Partial. +- known unsupported topology: Unsupported. +- unexpected open/query/enumeration failure with no usable observations: Failed for that scope. +- `ERROR_KEY_DELETED`: retry as concurrent mutation; exhausted retries become Partial. + +Aggregate status is a pure function: all Complete -> Complete; if every scope has the same terminal PermissionDenied/Unsupported/Unavailable/Failed status -> that status; every mixed result -> Partial. Status is never inferred from observation count. Complete or Partial scopes may carry observations; denied/unavailable/unsupported/failed scopes do not. + +### Pure Snapshot assembly + +Add a small core assembly function that accepts already sampled metadata and `CollectionOutcome` values. It sets document/schema constants, derives enabled Collector IDs and runs, flattens and sorts observations/coverage/diagnostics, calls `Snapshot::validate`, and returns the Snapshot. It owns no clock, Windows API, file path, or runtime Collector discovery. + +The CLI/platform layer supplies: + +- `systemdiff_version` from package build metadata; +- canonical current UTC formatted with `time` as RFC 3339 `Z` (reader support for `+00:00` remains); +- optional Windows major/minor and build from `windows-version`; +- native architecture from the same platform detection used for Registry topology; +- current-token privilege from `OpenProcessToken`, `GetTokenInformation(TokenUser/TokenElevation)`, and `IsWellKnownSid(WinLocalSystemSid)`, falling back honestly to `Unknown`; +- redaction `Unredacted` with no policy; +- only actually implemented Collector outcomes, initially Registry startup. + +No hostname, SID, stable machine token, account name, or network data is added. The sampled privilege value is reused in `CollectionContext`. + +## Snapshot CLI composition and output + +Add `snapshot -o ` while preserving existing commands. On non-Windows platforms the command parses but returns an explicit unsupported-platform error before creating an output file. + +Windows flow: + +```text +sample time + non-identifying host/privilege metadata + -> build CollectionContext + -> run the fixed list of implemented Collectors (Registry only) + -> pure assemble/sort/validate Snapshot + -> serialize pretty JSON + newline through a 64 MiB capped memory writer + -> OpenOptions::create_new(true) + -> write_all + flush +``` + +`create_new(true)` atomically rejects an existing path; do not use `exists()` followed by create and do not add `--force`. A small CLI-owned capped `Write` adapter stops serialization before memory exceeds 64 MiB; the report crate remains stream-only. If `write_all` or `flush` fails after this invocation creates the destination, do not delete by pathname because a concurrent rename/replacement could make that path refer to someone else's file. Return an explicit error naming the potentially incomplete output and require user inspection/removal. Tests use a fixed timestamp and injected writer/file helper rather than wall-clock sleeps. + +`mvp_collector_plans` gains an Implemented state for Registry while Services and Tasks remain Planned. The Registry descriptor is defined once and reused by the plan and Collector so ID/version/privilege cannot drift. + +## Implementation steps + +1. Reconfirm issue #5, this approved plan, synchronized `main`, dependency versions/licenses/features, and the supported Windows/architecture assumptions before code changes. +2. Add targeted dependencies: `windows` only for Windows with the minimum Win32 features, `windows-version`, `sha2` without unnecessary default features, and the existing `time` dependency's `std`/`formatting` features only where the CLI writer needs them. Update the architecture dependency table and lockfile. +3. Make the two Collector-driven draft-v1 schema changes in core: scoped diagnostics and lossless tagged Registry value names. Add invariant/fixture/round-trip tests and update data-format documentation. +4. Add the pure core Snapshot assembly function and deterministic ordering tests. +5. Split `systemdiff-windows` into focused modules for descriptors/composition, Registry evidence/Collector logic, the cfg-Windows Win32 adapter, and platform metadata. Keep the public surface minimal. +6. Implement the fixed Registry target planner for x64, x86 sole-view, ARM/unknown unsupported, and layout failure; add cross-platform table tests. +7. Implement exact UTF-16 name identity, RunOnce classification reuse, strict native decoding, SHA-256, the no-value-raw policy, aggregate status, and scripted-source tests before Win32 calls. +8. Implement the cfg-Windows RAII Registry adapter, metadata-first per-index reads, checked buffers, same-index `ERROR_MORE_DATA` retry, resource caps, whole-scope consistency retry, and numeric error mapping. Add narrow Windows read-only smoke tests. +9. Compose the implemented Registry Collector and platform metadata into the pure Snapshot assembler. Ensure unrelated scope results survive denial/failure. +10. Add CLI parsing/runtime for `snapshot -o`, canonical `Z`, capped <=64 MiB serialization, atomic no-overwrite creation, explicit partial-output errors without path deletion, and non-Windows unsupported behavior. +11. Add registry-only synthetic before/after fixtures and Snapshot -> Diff integration: exactly one expected Added change for complete coverage, and Inconclusive rather than false removal for denied/partial coverage. +12. Update canonical English and Chinese README status/quick-start parity, Collector/data-format/architecture/threat-model/roadmap documentation, and `.agent/PROJECT_STATE.md`. Do not market Services/Tasks or GUI as implemented. +13. Run format, Clippy, full workspace tests, three existing CLI smoke commands, new Snapshot command tests, dependency feature inspection, `git diff --check`, Markdown links, privacy/scope review, and independent reviewer analysis. +14. With explicit maintainer authorization, run the safe real-Windows HKCU E2E below, record only non-sensitive counts/status/versions, verify cleanup, and never commit or upload real Snapshot files. +15. Prepare a focused PR linked to #5 and wait for `Rust (windows-latest)` and `Rust (ubuntu-latest)`; do not merge without separate authorization. + +## Affected files and modules + +Expected production/dependency surface: + +- `Cargo.toml`, `Cargo.lock` +- `crates/systemdiff-core/src/lib.rs` and focused core tests +- `crates/systemdiff-windows/Cargo.toml` +- `crates/systemdiff-windows/src/lib.rs` +- new focused Registry/platform modules under `crates/systemdiff-windows/src/` +- `crates/systemdiff-cli/Cargo.toml` +- `crates/systemdiff-cli/src/main.rs` and optionally one focused Snapshot command module + +Expected fixtures/integration tests: + +- new Registry-only synthetic Snapshot fixtures under `fixtures/snapshots/` +- core, Windows, CLI, and Diff integration tests +- an explicitly gated test-only/manual E2E harness under `scripts/` only after implementation authorization + +Expected documentation/state: + +- `README.md`, `README.zh-CN.md` +- `docs/architecture.md` +- `docs/data-format.md` +- `docs/collectors.md` +- `docs/threat-model.md` +- `docs/roadmap.md` +- `.agent/PROJECT_STATE.md` +- this ExecPlan + +No production changes are expected in `systemdiff-diff`, `systemdiff-report`, `systemdiff-risk`, Services/Tasks artifacts, desktop, CI trigger behavior, or release tooling unless implementation demonstrates a concrete blocker and the maintainer approves the scope change. + +## Test strategy + +### Cross-platform pure tests + +- Target planner: HKCU Shared, x64 explicit Registry32/Registry64, x86 Native, ARM/unknown unsupported, fixed scope order. +- Adapter-independent value sets: missing/empty, one value, multiple unordered values, unnamed value, delimiter-like names, case/Unicode names, and deterministic ordering. +- Decode tables: valid/malformed REG_SZ, EXPAND_SZ, MULTI_SZ, DWORD little/big endian, QWORD, binary/none, unknown type, invalid UTF-16, odd byte count, invalid termination, zero length, embedded NUL, and `raw_evidence: None` for every status. +- Identity: exact Collector-v1 hash vectors, complete name, documented case-insensitive lookup versus the v1 exact-identity limitation, `Foo`/`!Foo`/`*Foo`, invalid UTF-16 units, unnamed/default value round-trip and stable identity, digest collision handling, duplicate prevention, and input-order independence. +- Retry/status: changed -> stable, changed -> changed -> stable, exhausted mutation, missing, denied, unexpected error, too-large value plus normal sibling retention, count/work cap, mixed scope aggregation, and unrelated evidence retention. +- Snapshot assembly: fixed metadata, canonical ordering, unknown privilege, scoped diagnostics, validation, deterministic JSON, and output <= reader maximum. +- CLI: parse `snapshot -o`, non-Windows unsupported, new path success, existing sentinel unchanged, missing parent/directory/open failure, capped-writer overflow before file creation, injected write/flush failure with explicit partial-file warning and no path deletion, canonical `Z`, and generated Snapshot reread/validation. +- Snapshot -> Diff: empty before/one value after yields one Added; partial/denied absence yields Inconclusive/no false Removed; shuffled source produces identical Snapshot and Diff bytes. + +### Windows read-only tests + +- Compile and exercise the real adapter without elevation. +- Enumerate the documented scopes visible to the runner but do not assert host-specific value counts or names. +- Verify view labels follow detected topology and handles/errors do not panic. +- Do not log Registry value names/data, raw bytes, commands, usernames, SID, or host-specific paths. +- Do not change ACLs or create/delete startup values in the default test suite. + +### Required validation commands + +```text +cargo fmt --all --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace --all-targets +cargo run --locked -p systemdiff-cli -- diff fixtures/snapshots/before-v1.json fixtures/snapshots/after-v1.json +cargo run --locked -p systemdiff-cli -- diff --json fixtures/snapshots/before-v1.json fixtures/snapshots/after-v1.json +cargo run --locked -p systemdiff-cli -- collectors +``` + +Also run focused generated-Snapshot/reopen/Diff tests, dependency feature trees, `git diff --check`, Markdown-link checks, schema fixture checks, and the real E2E only when separately authorized. Record exact counts/results; never infer success. + +## Real Windows E2E procedure + +The implementation may add a clearly test-only PowerShell harness, but production Rust must contain no Registry write API. The harness requires both an explicit switch such as `-ConfirmSyntheticRegistryTest` and `SYSTEMDIFF_RUN_SYNTHETIC_E2E=1`; it must not run from PR/push CI or ordinary ignored tests. + +Use only: + +```text +HKCU\Software\Microsoft\Windows\CurrentVersion\Run +value name: SystemDiffSyntheticE2E- +type: REG_SZ +data: quoted current %SystemRoot%\System32\cmd.exe /d /c exit 0 +``` + +Procedure: + +1. Prefer a disposable Windows VM. Create a fresh private temporary directory for `before.json`, `after.json`, and `diff.json`; do not upload artifacts. +2. Confirm the Run key already exists. Read the exact test value first; abort if the name already exists. Never overwrite or delete pre-existing data. +3. Establish `try/finally` cleanup before mutation and compute the exact expected no-op string. +4. Run `systemdiff snapshot -o before.json` and record only the command/exit status and capture timestamp suffix. +5. Create only the test value as REG_SZ and read it back to confirm exact name/type/data. Do not log off or execute the command. +6. Run `systemdiff snapshot -o after.json` and `systemdiff diff before.json after.json` plus JSON output. +7. Assert the expected Registry observation is Added with HKCU, Shared, Run, the complete synthetic name, no RunOnce marker semantics, and zero Removed changes. Any unrelated removal fails acceptance. +8. In `finally`, delete the value only if its current type and full data still exactly match the harness-owned expected value. If another actor changed it, refuse destructive cleanup and require manual inspection. +9. Re-read and require `cleanup_verified_absent=true`. Delete only the harness-owned temporary report files after recording non-sensitive counts. A cleanup failure makes the E2E fail. +10. Provide an idempotent recovery-only mode that removes a leftover value only when its exact type/data match the known no-op. A new run encountering an existing test name aborts rather than overwriting it. + +The acceptance record contains commit SHA, UTC start/end, `rustc -Vv`, Windows edition/build/native architecture, standard/elevated only, command exit codes, test totals, `Added=1`, `Removed=0`, expected identity matched, `cleanup_verified_absent=true`, and confirmation that real Snapshots were deleted and never uploaded. It contains no Registry payloads, usernames, SID, hostname, or real report attachments. + +## Privacy implications + +- Startup values can expose usernames, installation paths, command arguments, tokens, or secrets. Snapshots remain `Unredacted`; CLI/docs must warn that they are sensitive and must not be attached to public Issues. +- Hashes of low-entropy values can be guessed. A hash is comparison evidence, not anonymization. +- Invalid-name UTF-16 hex can be more sensitive than decoded display text, so it appears only when required to preserve authoritative name identity. Collector v1 emits no value-data raw prefixes. +- Diagnostics and CI logs never include observed names or data. Stable scope IDs refer only to the four documented locations/views. +- Host metadata excludes hostname, SID, account name, and stable machine identifiers. +- No network client, telemetry, account, or upload path is introduced. + +## Risks + +- **False completeness during Registry mutation:** count/FILETIME checks are not atomic. Mitigate with fresh-handle whole-scope retries, bounded per-index growth, Partial on exhausted instability, and no false-removal regressions. +- **FFI memory/logic errors:** lengths mix UTF-16 units and bytes. Mitigate with initialized buffers, checked arithmetic, length validation before slicing, minimal unsafe blocks, RAII handles, and pure decoder tests. +- **Resource exhaustion:** Registry values can be very large and the 64 MiB input limit does not protect collection. Mitigate with per-value/Collector/work caps and Partial rather than truncated hashes. +- **Lossy value names/identity collision:** a Rust String cannot represent every UTF-16 sequence. Mitigate with tagged lossless evidence, an exact version-independent UTF-16 identity algorithm, and explicit digest-collision handling. +- **ARM view ambiguity:** the 32-bit selector varies by calling architecture. Mitigate by marking HKLM unsupported until a tested ARM contract exists. +- **Output overwrite/partial files:** use atomic create-new, capped serialization before creation, and explicit write/flush errors. Never remove a failed output by pathname; leave a potentially partial file for user inspection rather than risk deleting a concurrent replacement. +- **Privacy disclosure:** real Snapshots and raw bytes are sensitive. Mitigate with no uploads, synthetic fixtures, log discipline, and deletion of E2E artifacts. +- **Test harness becomes write functionality:** keep it outside production, dual-gated, HKCU-only, exact-name/data guarded, and manually authorized. +- **Scope growth:** metadata/schema work may tempt generic frameworks. Keep the two schema changes and one assembly helper tied to demonstrated Collector requirements. + +## Rollback and compatibility + +This work changes the unreleased draft-v1 Registry value-name field and adds an optional Diagnostic scope before v0.1. Update all golden fixtures. Old Registry observations with a bare string name are explicitly rejected by the tagged-name schema; an older Diagnostic with no `scope_id` is intentionally accepted as `None` and documented as Collector-wide. The PR is reversible as one vertical slice; reverting restores the fixture-only state and removes `snapshot` rather than leaving a half-implemented Collector advertised as available. + +Once v0.1 ships, the released schema, Collector ID/version, scope IDs, view semantics, identity algorithm, diagnostic codes, and decode rules become compatibility obligations. Future behavior changes require an explicit Collector version and, where wire-incompatible, a new schema route. + +No migration framework is introduced in this issue. Existing synthetic Diff semantics and report output must remain deterministic. + +## Progress + +- [x] 2026-08-11: Squash-merged PR #4, confirmed issue #3 closed, synchronized local/remote main, removed the fully integrated local branch after tree equality, and observed green merge CI on Windows and Ubuntu. +- [x] 2026-08-11: Confirmed no equivalent open or closed Issue and created focused product issue #5. +- [x] 2026-08-11: Completed read-only explorer, Microsoft Windows API, dependency, and test/E2E investigations. +- [x] 2026-08-11: Drafted this implementation plan without production code changes. +- [x] 2026-08-11: Closed four Medium and one Low plan-review findings; the second independent review reported High 0 / Medium 0 / Low 0. +- [x] 2026-08-11: Maintainer approved the schema, Windows floor, ARM limitation, resource budgets, and dual-gated E2E, then authorized implementation. +- [x] 2026-08-11: Verified value-name casing semantics against Microsoft documentation and an isolated build-26100 HKCU experiment; alternate-case `RegSetValueExW` kept one value and first enumeration casing, and exact-scope cleanup was verified. +- [x] Implemented the approved vertical slice: lossless/scoped core schema updates, pure assembly, bounded Win32 Registry adapter, strict normalization, `snapshot` CLI, Registry-only fixtures, and the guarded E2E harness. +- [x] Ran the dual-gated real HKCU E2E: the synthetic value was absent before, present after, classified as exactly one Added change with zero Removed changes, and exact-data cleanup plus temporary-file deletion were verified. +- [x] Addressed the first implementation review (High 1 / Medium 3): native value names now participate in the aggregate retained-evidence budget with order-independent bounded selection, LocalSystem is identified from TokenUser before elevation classification, canonical fixtures use production v1 scopes/identity, and the Win32 buffer/resource state machine has direct regression coverage. +- [x] Hardened the E2E harness with a random guarded value name and non-recursive exact-entry temporary cleanup, then reran the real E2E and recovery check successfully. +- [x] Completed final local gates and third-round independent review; reviewer reported High 0 / Medium 0 / Low 0. +- [x] Created commit `c3966a3`, pushed the feature branch, and opened PR #6 linked to Issue #5. +- [ ] Fix the Ubuntu target-specific dead-code Clippy failure and wait for both required GitHub CI checks. + +## Discoveries + +- The existing Collector trait, Registry artifact, coverage validation, Diff, and generic JSON reporter are sufficient; redesigning them would be premature. +- `Diagnostic` lacks a structured scope link, which becomes ambiguous as soon as six independent Registry scopes exist. +- Registry value data can be invalid UTF-16 and string types can lack documented terminators; full bytes must be hashed before strict decoding. +- Registry value names arrive as UTF-16 units, and Microsoft does not guarantee that every stored sequence is representable as a Rust String. Lossy conversion would corrupt evidence and identity. +- `RegQueryInfoKeyW` maxima/count/last-write are momentary hints, not an atomic Snapshot. `RegEnumValueW` can require same-index buffer retry and returns values in unspecified order. +- `HKEY` from the low-level windows-rs binding needs project-owned RAII; the official higher-level `windows-registry` iterator's lossy/error-hiding behavior is insufficient for forensic collection. +- A 64 MiB file-reader ceiling does not bound native collection allocation. The Registry Collector needs small, explicit local limits. +- ARM64 `KEY_WOW64_32KEY` semantics depend on calling architecture, so the current Registry32 label is not enough to promise cross-build ARM equivalence. +- Snapshot file no-overwrite belongs in CLI; report remains stream-only. Snapshot assembly can be one pure core function rather than a builder framework. +- Registry value lookup is case-insensitive, but no documented Windows API produces a durable cross-platform token guaranteed equivalent to Registry matching. Exact UTF-16 is the safer v1 evidence identity because a visible false split is preferable to a hidden false merge. +- Native Registry value names are attacker-controlled evidence too; a value-data-only budget leaves a large allocation gap. The Collector budget therefore accounts for retained UTF-16 name bytes and native data bytes together. + +## Decisions + +- Use lower-level `windows` 0.62.2 bindings with narrow features, a private RAII HKEY, and a narrow Registry source interface. Do not use command output or the current high-level `windows-registry` iterator. +- Use `KEY_QUERY_VALUE` only, plus exactly one WOW selector where required. +- Use six scopes on x64, four on sole-view x86, and honest Unsupported HKLM scopes on ARM/unknown; HKCU Shared remains available. +- Use three total whole-scope attempts and bounded same-index buffer growth. Exhausted mutation/resource conditions produce Partial, never invented absence. +- Add only the two schema changes demonstrated by real collection: scoped diagnostics and lossless tagged Registry value names. +- Hash complete native data bytes before strict decoding. Emit no value-data raw prefix in Collector v1; revisit only for a concrete, privacy-reviewed forensic need. +- Use a fully specified domain-separated SHA-256 over exact UTF-16 name units. Perform no case/Unicode normalization, retain prefix participation, and reject digest collisions. +- Add one pure Snapshot assembly helper and keep clock, Windows metadata, and filesystem output at outer boundaries. +- Use canonical UTC `Z`, serialize through a capped 64 MiB writer before output creation, and create the destination with `create_new(true)`. Report but do not path-delete a partial file after write/flush failure. +- Keep the write-based synthetic procedure test-only, dual-gated, HKCU-only, guarded against pre-existing/mutated values, and absent from default CI. +- Treat Windows 10/Server version 1709 as the initial runtime floor for this vertical slice because it provides `IsWow64Process2`; document the floor rather than guessing on older systems. +- Treat the exact-name identity as a documented Collector-v1 limitation rather than Windows logical-name semantics; do not substitute unverified NLS uppercase/sort/hash tokens. + +## Maintainer approvals + +Implementation is authorized with these decisions: + +1. Minimum runtime is Windows 10 version 1709 / Windows Server 2016 version 1709, aligned with `IsWow64Process2` support. +2. ARM64 v1 collects HKCU Shared and marks HKLM alternate-view coverage Unsupported without expanding `RegistryView`. +3. Tagged lossless `RegistryValueName` and scoped `Diagnostic` are approved pre-v0.1 schema changes. +4. Capture limits are 8 MiB of native data per Registry value, 32 MiB aggregate retained native Registry value-name and value-data evidence, and 4,096 values per scope. These are SystemDiff resource limits, not Windows platform limits. +5. The dual-gated, test-only, HKCU-only write harness is approved. No Registry write API enters the production CLI, Collector, or public library API. + +## Final validation + +Local validation completed on 2026-08-11 with stable `rustc 1.97.1` (`x86_64-pc-windows-msvc`) on Windows build 26100: + +- `cargo fmt --all --check`: passed. +- `cargo clippy --locked --workspace --all-targets -- -D warnings`: passed. +- `cargo test --locked --workspace --all-targets`: 71 passed, 0 failed, 0 ignored. +- Both fixture Diff commands and `collectors`: exit 0. Terminal fixture totals remained Added 2, Modified 1, Removed 1, Inconclusive 1; JSON contained the same five changes; Registry startup reported Implemented while Services and Scheduled Tasks remained Planned. +- The final dual-gated real Windows E2E captured an absent synthetic HKCU Shared Run value before mutation, observed the exact synthetic `REG_SZ` after mutation, classified exactly one Added and zero Removed changes with the expected identity, verified exact-data guarded deletion, deleted both temporary Snapshots, and confirmed absence again through recovery mode. No real Snapshot was retained, uploaded, or committed. +- `git diff --check`, modified-Markdown local-link checks, dependency-feature inspection, production Registry-write API search, local-path/secret scan, and English/Chinese README factual-parity review passed. +- The final independent reviewer reported High 0 / Medium 0 / Low 0 after confirming enumeration-count mismatch retry, resource-cap semantics, evidence budgets, LocalSystem metadata, canonical fixtures, buffer tests, read-only boundaries, E2E cleanup, schema, privacy, and documentation. + +Commit/PR identifiers and the two required remote checks will be recorded in the PR and final maintainer report because a commit cannot contain its own hash. diff --git a/Cargo.lock b/Cargo.lock index 842c97c..3d944ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,21 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.6" @@ -83,7 +98,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -98,18 +113,55 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -122,6 +174,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + [[package]] name = "memchr" version = "2.8.3" @@ -191,7 +249,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -207,12 +265,34 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -229,10 +309,12 @@ name = "systemdiff-cli" version = "0.0.0" dependencies = [ "clap", + "serde_json", "systemdiff-core", "systemdiff-diff", "systemdiff-report", "systemdiff-windows", + "time", ] [[package]] @@ -275,7 +357,10 @@ dependencies = [ name = "systemdiff-windows" version = "0.0.0" dependencies = [ + "sha2", "systemdiff-core", + "windows", + "windows-version", ] [[package]] @@ -287,6 +372,7 @@ dependencies = [ "deranged", "num-conv", "powerfmt", + "serde_core", "time-core", "time-macros", ] @@ -307,6 +393,12 @@ dependencies = [ "time-core", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -319,12 +411,107 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -334,6 +521,24 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 6661f05..b9843ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,10 @@ authors = ["SystemDiff contributors"] clap = { version = "4.5", features = ["derive"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -time = { version = "0.3.55", default-features = false, features = ["parsing"] } +sha2 = { version = "0.11.0", default-features = false } +time = { version = "0.3.55", default-features = false, features = ["formatting", "parsing", "std"] } +windows = { version = "0.62.2", default-features = false } +windows-version = "0.1.7" systemdiff-core = { path = "crates/systemdiff-core" } systemdiff-diff = { path = "crates/systemdiff-diff" } systemdiff-report = { path = "crates/systemdiff-report" } diff --git a/README.md b/README.md index b2e7cb7..67e9be4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **SystemDiff shows what changed on a Windows system, with plain-language explanations backed by inspectable evidence.** > [!IMPORTANT] -> SystemDiff is in repository bootstrap. There is no end-user release and no operating-system Collector is implemented yet. The current code proves the draft schema, deterministic diff, report, and contribution boundaries; it is not an effective system scanner today. +> SystemDiff is pre-release software with no end-user distribution yet. The development CLI now captures the documented Windows Run/RunOnce startup locations through its first real read-only Collector and can compare two Snapshots. Services, Scheduled Tasks, explanations/rules, redaction, and the desktop app are not implemented. ## Why SystemDiff? @@ -38,7 +38,7 @@ ExampleApp settings Usually harmless ``` -This output is illustrative, not a current detection claim. Advanced users will be able to inspect exact registry paths, service/task configuration, raw before/after values, Collector and rule IDs, structured JSON, hashes, and signature metadata when supported. +This output is illustrative, not a current detection claim. Current Registry diffs expose exact paths, value names/types, typed decode status, complete-value SHA-256, Collector/scope identity, and structured JSON. Service/task evidence, rules, and signature metadata remain planned. SystemDiff does not equate unusual with malicious. Explanations sit on top of evidence; they never replace it. @@ -54,7 +54,7 @@ SystemDiff does not equate unusual with malicious. Explanations sit on top of ev See [product principles](docs/product-principles.md) and the [threat model](docs/threat-model.md). -## Planned v0.1 workflow +## Current pre-v0.1 workflow ```powershell systemdiff snapshot -o before.json @@ -65,7 +65,9 @@ systemdiff snapshot -o after.json systemdiff diff before.json after.json ``` -The `snapshot` command is intentionally not implemented in the bootstrap. v0.1 will be complete only when this pipeline works reliably. +This pipeline now works from a source build on supported Windows systems for Registry Run/RunOnce evidence only. Snapshot files are unredacted and may contain sensitive command strings and paths. v0.1 is not complete until all required Collectors and the full workflow are reliable. + +The current minimum platform is Windows 10 version 1709 or Windows Server 2016 version 1709. ARM64 captures current-user shared Registry scopes, but v1 explicitly reports HKLM alternate-view coverage as unsupported until those view semantics can be represented and tested correctly. The draft v0.1 comparison model assumes that both snapshots come from the same Windows installation and the same user/principal context. Cross-host and cross-user identity are intentionally out of scope. @@ -73,7 +75,7 @@ The draft v0.1 comparison model assumes that both snapshots come from the same W | Collector | v0.1 scope | Current status | | --- | --- | --- | -| Registry startup | Documented Run/RunOnce locations and correct Registry views | Planned | +| Registry startup | Documented Run/RunOnce locations and explicit Registry views | Implemented in the development CLI | | Windows services | Stable Win32 service configuration; drivers excluded | Planned | | Scheduled tasks | Task Scheduler 2.0 configuration with permission-aware coverage | Planned | @@ -99,7 +101,7 @@ cargo run --locked -p systemdiff-cli -- diff --json fixtures/snapshots/before-v1 cargo run --locked -p systemdiff-cli -- collectors ``` -The bootstrap workspace has been validated with a real stable Rust MSVC toolchain. See [.agent/PROJECT_STATE.md](.agent/PROJECT_STATE.md) for the exact validated state and remaining product limitations. +The workspace and the opt-in synthetic HKCU Registry E2E have been validated with a real stable Rust MSVC toolchain. The E2E harness is test-only, requires two explicit gates, refuses to overwrite an existing value, and is not run by default CI. See [.agent/PROJECT_STATE.md](.agent/PROJECT_STATE.md) for the exact validated state and remaining product limitations. ## Architecture diff --git a/README.zh-CN.md b/README.zh-CN.md index 738ee64..18cd81e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ **SystemDiff 帮你看清 Windows 系统发生了什么变化——每一条结论都附带可查证的证据。** > [!IMPORTANT] -> SystemDiff 目前处于项目搭建阶段,尚无可分发的正式版本,也没有真正调用操作系统 API 的 Collector。当前代码仅用于验证 schema 草案、确定性 Diff、报告生成和贡献流程;它现在还不是一个能用的系统扫描工具。 +> SystemDiff 仍是没有面向普通用户发行包的预发布软件。开发版 CLI 现在已经能通过第一个真实、只读的 Collector 采集 Windows 官方文档中的 Run/RunOnce 启动项,并比较两份 Snapshot。Windows 服务、计划任务、解释规则、脱敏和桌面应用均尚未实现。 ## 为什么要做 SystemDiff? @@ -36,7 +36,7 @@ ExampleApp 设置 通常无害 ``` -以上只是效果示意,不代表目前已有对应的检测能力。后续高级用户将能够查看精确的注册表路径、服务/任务配置、变更前后的原始值、Collector 和 rule ID、结构化 JSON,以及(在支持后)哈希和签名元数据。 +以上只是效果示意,不代表目前已有对应的检测能力。当前的注册表 Diff 已可显示精确路径、value name/type、类型化解码状态、完整 value 的 SHA-256、Collector/scope identity 和结构化 JSON;服务/任务证据、规则和签名元数据仍在计划中。 SystemDiff 不会把“不常见”直接等同于“恶意”。解释始终建立在证据之上,而不是取代证据。 @@ -52,7 +52,7 @@ SystemDiff 不会把“不常见”直接等同于“恶意”。解释始终建 详见[产品原则](docs/product-principles.md)和[威胁模型](docs/threat-model.md)。 -## v0.1 计划流程 +## 当前 pre-v0.1 流程 ```powershell systemdiff snapshot -o before.json @@ -63,7 +63,9 @@ systemdiff snapshot -o after.json systemdiff diff before.json after.json ``` -`snapshot` 命令在搭建阶段有意没有实现。v0.1 只有在整条链路稳定工作后才算完成。 +从源代码构建后,这条链路目前可在受支持的 Windows 系统上处理 Registry Run/RunOnce 证据。Snapshot 尚未脱敏,可能包含敏感的命令字符串和路径。只有所需 Collector 和完整流程都可靠后,v0.1 才算完成。 + +当前最低支持 Windows 10 version 1709 或 Windows Server 2016 version 1709。ARM64 会采集当前用户的 Shared Registry scope;在能够正确表达并测试相关 view semantics 前,v1 会明确将 HKLM alternate-view coverage 报告为 `unsupported`。 v0.1 的 Diff 只用于比较同一套 Windows 系统、同一用户/主体上下文中的 before/after Snapshot;跨主机或跨用户身份关联不在当前范围内。 @@ -71,7 +73,7 @@ v0.1 的 Diff 只用于比较同一套 Windows 系统、同一用户/主体上 | Collector | v0.1 范围 | 当前状态 | | --- | --- | --- | -| 注册表启动项 | 官方文档列出的 Run/RunOnce 位置,并正确处理注册表视图(Registry view) | 计划中 | +| 注册表启动项 | 官方文档列出的 Run/RunOnce 位置和明确的 Registry view | 已在开发版 CLI 中实现 | | Windows 服务 | 稳定的 Win32 服务配置;不含驱动 | 计划中 | | 计划任务 | Task Scheduler 2.0 配置,并明确显示因权限不足造成的覆盖缺口 | 计划中 | @@ -97,7 +99,7 @@ cargo run --locked -p systemdiff-cli -- diff --json fixtures/snapshots/before-v1 cargo run --locked -p systemdiff-cli -- collectors ``` -本 workspace 已在真实的 stable Rust MSVC toolchain 下验证通过。确切的验证状态和剩余产品限制见 [.agent/PROJECT_STATE.md](.agent/PROJECT_STATE.md)。 +本 workspace 和 opt-in synthetic HKCU Registry E2E 已在真实的 stable Rust MSVC toolchain 下验证。E2E harness 只用于测试,需要两项显式 gate,会拒绝覆盖现有 value,默认 CI 不会运行。确切的验证状态和剩余产品限制见 [.agent/PROJECT_STATE.md](.agent/PROJECT_STATE.md)。 ## 架构 diff --git a/crates/systemdiff-cli/Cargo.toml b/crates/systemdiff-cli/Cargo.toml index 2e9104e..64a36e8 100644 --- a/crates/systemdiff-cli/Cargo.toml +++ b/crates/systemdiff-cli/Cargo.toml @@ -12,7 +12,11 @@ path = "src/main.rs" [dependencies] clap.workspace = true +time.workspace = true systemdiff-core.workspace = true systemdiff-diff.workspace = true systemdiff-report.workspace = true systemdiff-windows.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/systemdiff-cli/src/main.rs b/crates/systemdiff-cli/src/main.rs index f30951b..84d3dac 100644 --- a/crates/systemdiff-cli/src/main.rs +++ b/crates/systemdiff-cli/src/main.rs @@ -3,23 +3,26 @@ use clap::{Parser, Subcommand}; use std::error::Error; use std::fmt; -use std::fs::File; -use std::io::{self, Read}; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use systemdiff_core::{Snapshot, decode_snapshot_document}; use systemdiff_diff::{DiffOptions, diff_snapshots}; use systemdiff_report::{write_json, write_terminal}; -use systemdiff_windows::mvp_collector_plans; +use systemdiff_windows::{capture_snapshot, mvp_collector_plans}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; const MAX_SNAPSHOT_INPUT_BYTES: u64 = 64 * 1024 * 1024; +const MAX_SNAPSHOT_OUTPUT_BYTES: usize = 64 * 1024 * 1024; #[derive(Debug, Parser)] #[command( name = "systemdiff", version, about = "Compare versioned Windows system evidence", - long_about = "SystemDiff is in bootstrap. The diff command works with draft synthetic snapshots; operating-system collection is not implemented yet." + long_about = "Capture documented Windows Run/RunOnce startup evidence and compare versioned before/after Snapshots." )] struct Cli { #[command(subcommand)] @@ -28,6 +31,13 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Capture the currently implemented Windows evidence Collectors. + Snapshot { + /// New Snapshot file to create. Existing files are never overwritten. + #[arg(short = 'o', long)] + output: PathBuf, + }, + /// Compare two draft SystemDiff snapshot files. Diff { /// Render the versioned JSON diff instead of terminal text. @@ -61,6 +71,18 @@ fn main() -> ExitCode { fn run(cli: Cli) -> Result<(), Box> { match cli.command { + Command::Snapshot { output } => { + let captured_at = OffsetDateTime::now_utc().format(&Rfc3339)?; + if !captured_at.ends_with('Z') { + return Err(Box::new(CliError( + "internal timestamp formatter did not produce canonical UTC Z".to_owned(), + ))); + } + let snapshot = capture_snapshot(captured_at, env!("CARGO_PKG_VERSION").to_owned())?; + let bytes = serialize_snapshot_with_limit(&snapshot, MAX_SNAPSHOT_OUTPUT_BYTES)?; + create_snapshot_file(&output, &bytes)?; + println!("Created Snapshot: {}", output.display()); + } Command::Diff { json, include_unchanged, @@ -94,6 +116,89 @@ fn run(cli: Cli) -> Result<(), Box> { Ok(()) } +fn serialize_snapshot_with_limit( + snapshot: &Snapshot, + maximum_bytes: usize, +) -> Result, CliError> { + let mut output = CappedBuffer::new(maximum_bytes); + if let Err(error) = write_json(&mut output, snapshot) { + if output.exceeded { + return Err(CliError(format!( + "generated Snapshot exceeds the maximum supported size of {maximum_bytes} bytes" + ))); + } + return Err(CliError(format!( + "failed to serialize generated Snapshot: {error}" + ))); + } + Ok(output.bytes) +} + +fn create_snapshot_file(path: &Path, bytes: &[u8]) -> Result<(), CliError> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| { + CliError(format!( + "failed to create new Snapshot {}: {error}", + path.display() + )) + })?; + write_created_output(&mut file, bytes).map_err(|error| { + CliError(format!( + "failed to finish Snapshot {}: {error}; the newly created file may be incomplete and was not deleted automatically", + path.display() + )) + }) +} + +fn write_created_output(writer: &mut W, bytes: &[u8]) -> io::Result<()> { + writer.write_all(bytes)?; + writer.flush() +} + +struct CappedBuffer { + bytes: Vec, + maximum_bytes: usize, + exceeded: bool, +} + +impl CappedBuffer { + fn new(maximum_bytes: usize) -> Self { + Self { + bytes: Vec::new(), + maximum_bytes, + exceeded: false, + } + } +} + +impl Write for CappedBuffer { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let Some(next_length) = self.bytes.len().checked_add(buffer.len()) else { + self.exceeded = true; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Snapshot output size overflow", + )); + }; + if next_length > self.maximum_bytes { + self.exceeded = true; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Snapshot output exceeds configured limit", + )); + } + self.bytes.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + fn load_snapshot(path: &Path) -> Result { load_snapshot_with_limit(path, MAX_SNAPSHOT_INPUT_BYTES) } @@ -200,8 +305,13 @@ mod tests { } #[test] - fn snapshot_command_is_not_advertised_before_collectors_exist() { - assert!(Cli::try_parse_from(["systemdiff", "snapshot"]).is_err()); + fn parses_snapshot_output_command() { + let cli = Cli::try_parse_from(["systemdiff", "snapshot", "-o", "snapshot.json"]) + .expect("snapshot command must parse"); + assert!(matches!( + cli.command, + Command::Snapshot { ref output } if output == Path::new("snapshot.json") + )); } #[test] @@ -233,4 +343,81 @@ mod tests { assert!(error.0.contains("is too large")); assert!(!error.0.contains("parse snapshot")); } + + #[test] + fn generated_snapshot_is_bounded_before_file_creation() { + let snapshot: Snapshot = + serde_json::from_slice(include_bytes!("../../../fixtures/snapshots/before-v1.json")) + .expect("fixture must deserialize"); + let error = serialize_snapshot_with_limit(&snapshot, 1) + .expect_err("a tiny generated-output limit must be enforced"); + assert!(error.0.contains("exceeds the maximum supported size")); + } + + #[cfg(windows)] + #[test] + fn real_read_only_snapshot_serializes_and_reopens_with_explicit_registry_scopes() { + let captured_at = "2026-08-11T00:00:00Z"; + let snapshot = capture_snapshot(captured_at.to_owned(), "0.0.0-test".to_owned()) + .expect("supported Windows test host must produce a read-only Snapshot"); + let bytes = serialize_snapshot_with_limit(&snapshot, MAX_SNAPSHOT_OUTPUT_BYTES) + .expect("generated Snapshot must fit its own reader boundary"); + let reparsed = decode_snapshot_document(&bytes) + .expect("generated Snapshot must reopen through header-first routing"); + + assert_eq!(reparsed.captured_at, captured_at); + assert_eq!(reparsed.enabled_collectors, ["windows.registry.startup"]); + let registry = reparsed + .collectors + .iter() + .find(|run| run.id == "windows.registry.startup") + .expect("Registry startup Collector run must exist"); + assert!(registry.coverage.iter().any(|coverage| { + coverage.scope_id == "current_user.shared.run" + && matches!( + coverage.status, + systemdiff_core::CollectorStatus::Complete + | systemdiff_core::CollectorStatus::Partial + | systemdiff_core::CollectorStatus::PermissionDenied + ) + })); + assert!( + registry + .coverage + .iter() + .any(|coverage| coverage.scope_id == "current_user.shared.run_once") + ); + } + + #[test] + fn snapshot_output_never_overwrites_existing_file() { + let path = write_temp_snapshot(b"sentinel"); + let error = create_snapshot_file(&path, b"replacement") + .expect_err("an existing Snapshot path must be rejected"); + assert!(error.0.contains("failed to create new Snapshot")); + assert_eq!( + fs::read(&path).expect("sentinel must remain readable"), + b"sentinel" + ); + fs::remove_file(path).expect("temporary sentinel must be removed"); + } + + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::Error::other("injected write failure")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn created_output_write_failure_is_reported_without_cleanup_side_effects() { + let error = write_created_output(&mut FailingWriter, b"snapshot") + .expect_err("injected write failure must be returned"); + assert_eq!(error.kind(), io::ErrorKind::Other); + } } diff --git a/crates/systemdiff-core/src/lib.rs b/crates/systemdiff-core/src/lib.rs index 3afb9ff..e3f648f 100644 --- a/crates/systemdiff-core/src/lib.rs +++ b/crates/systemdiff-core/src/lib.rs @@ -10,6 +10,7 @@ use time::{OffsetDateTime, UtcOffset}; pub const SNAPSHOT_DOCUMENT_TYPE: &str = "systemdiff.snapshot"; pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; pub const REGISTRY_RAW_EVIDENCE_MAX_CAPTURE_BYTES: u64 = 4_096; +pub const REGISTRY_VALUE_NAME_MAX_UTF16_UNITS: usize = 16_383; #[derive(Debug, Deserialize)] struct SnapshotDocumentHeader { @@ -154,6 +155,27 @@ impl Snapshot { }); } } + + for diagnostic in &run.diagnostics { + if diagnostic.code.trim().is_empty() { + return Err(SnapshotValidationError::EmptyField( + "collectors[].diagnostics[].code", + )); + } + if let Some(scope_id) = &diagnostic.scope_id { + if scope_id.trim().is_empty() { + return Err(SnapshotValidationError::EmptyField( + "collectors[].diagnostics[].scope_id", + )); + } + if !scopes.contains(scope_id.as_str()) { + return Err(SnapshotValidationError::DiagnosticReferencesUnknownScope { + collector_id: run.id.clone(), + scope_id: scope_id.clone(), + }); + } + } + } } for collector_id in &self.enabled_collectors { @@ -301,6 +323,8 @@ pub struct Diagnostic { pub message: String, pub stage: Option, pub native_code: Option, + #[serde(default)] + pub scope_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -364,7 +388,7 @@ pub struct RegistryStartupEntry { pub hive: RegistryHive, pub registry_view: RegistryView, pub key_path: String, - pub value_name: String, + pub value_name: RegistryValueName, pub startup_kind: RegistryStartupKind, pub run_once_prefix: Option, pub value_type: u32, @@ -373,6 +397,49 @@ pub struct RegistryStartupEntry { pub raw_evidence: Option, } +/// Lossless Registry value-name evidence. +/// +/// Win32 returns UTF-16 code units. Valid Unicode remains convenient JSON text; +/// malformed UTF-16 remains exact lowercase UTF-16LE hex instead of being +/// replaced with U+FFFD. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "encoding", rename_all = "snake_case")] +pub enum RegistryValueName { + Decoded { value: String }, + InvalidUtf16 { utf16le_hex: String }, +} + +impl RegistryValueName { + pub fn decoded(value: impl Into) -> Self { + Self::Decoded { + value: value.into(), + } + } + + pub fn from_utf16_units(units: &[u16]) -> Self { + match String::from_utf16(units) { + Ok(value) => Self::Decoded { value }, + Err(_) => Self::InvalidUtf16 { + utf16le_hex: encode_utf16le_hex(units), + }, + } + } + + pub fn utf16_units(&self) -> Option> { + match self { + Self::Decoded { value } => Some(value.encode_utf16().collect()), + Self::InvalidUtf16 { utf16le_hex } => decode_utf16le_hex(utf16le_hex), + } + } + + pub fn decoded_value(&self) -> Option<&str> { + match self { + Self::Decoded { value } => Some(value), + Self::InvalidUtf16 { .. } => None, + } + } +} + /// Identifies which documented startup key produced an observation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -427,6 +494,7 @@ pub struct RegistryRawEvidence { fn validate_registry_startup_entry( entry: &RegistryStartupEntry, ) -> Result<(), SnapshotValidationError> { + let value_name_units = validate_registry_value_name(&entry.value_name)?; let key_kind = entry.key_path.rsplit('\\').next().and_then(|key_name| { if key_name.eq_ignore_ascii_case("Run") { Some(RegistryStartupKind::Run) @@ -451,7 +519,7 @@ fn validate_registry_startup_entry( } } RegistryStartupKind::RunOnce => { - let expected = classify_run_once_prefix(&entry.value_name); + let expected = classify_run_once_prefix_units(&value_name_units); if entry.run_once_prefix != Some(expected) { return Err(SnapshotValidationError::InvalidRegistryEvidence { field: "value_name/run_once_prefix", @@ -498,15 +566,21 @@ fn validate_registry_startup_entry( Ok(()) } -fn classify_run_once_prefix(value_name: &str) -> RunOncePrefixSemantics { - if let Some(remainder) = value_name.strip_prefix('!') { - if remainder.is_empty() || remainder.starts_with('!') || remainder.starts_with('*') { +pub fn classify_run_once_prefix_units(value_name: &[u16]) -> RunOncePrefixSemantics { + if let Some(remainder) = value_name.strip_prefix(&[u16::from(b'!')]) { + if remainder.is_empty() + || remainder.starts_with(&[u16::from(b'!')]) + || remainder.starts_with(&[u16::from(b'*')]) + { RunOncePrefixSemantics::Undocumented } else { RunOncePrefixSemantics::DeferDeletionUntilAfterRun } - } else if let Some(remainder) = value_name.strip_prefix('*') { - if remainder.is_empty() || remainder.starts_with('!') || remainder.starts_with('*') { + } else if let Some(remainder) = value_name.strip_prefix(&[u16::from(b'*')]) { + if remainder.is_empty() + || remainder.starts_with(&[u16::from(b'!')]) + || remainder.starts_with(&[u16::from(b'*')]) + { RunOncePrefixSemantics::Undocumented } else { RunOncePrefixSemantics::RunInSafeMode @@ -516,6 +590,68 @@ fn classify_run_once_prefix(value_name: &str) -> RunOncePrefixSemantics { } } +fn validate_registry_value_name( + value_name: &RegistryValueName, +) -> Result, SnapshotValidationError> { + let units = + value_name + .utf16_units() + .ok_or(SnapshotValidationError::InvalidRegistryEvidence { + field: "value_name", + })?; + if units.len() > REGISTRY_VALUE_NAME_MAX_UTF16_UNITS || units.contains(&0) { + return Err(SnapshotValidationError::InvalidRegistryEvidence { + field: "value_name", + }); + } + match value_name { + RegistryValueName::Decoded { .. } => {} + RegistryValueName::InvalidUtf16 { .. } => { + if units.is_empty() || String::from_utf16(&units).is_ok() { + return Err(SnapshotValidationError::InvalidRegistryEvidence { + field: "value_name", + }); + } + } + } + Ok(units) +} + +fn encode_utf16le_hex(units: &[u16]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(units.len().saturating_mul(4)); + for unit in units { + for byte in unit.to_le_bytes() { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + encoded +} + +fn decode_utf16le_hex(encoded: &str) -> Option> { + if !encoded.len().is_multiple_of(4) || !is_lower_hex(encoded, encoded.len()) { + return None; + } + encoded + .as_bytes() + .chunks_exact(4) + .map(|chunk| { + let low = (hex_nibble(chunk[0])? << 4) | hex_nibble(chunk[1])?; + let high = (hex_nibble(chunk[2])? << 4) | hex_nibble(chunk[3])?; + Some(u16::from_le_bytes([low, high])) + }) + .collect() +} + +fn hex_nibble(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + } +} + fn decoded_value_matches_type(value_type: u32, value: &RegistryDecodedValue) -> bool { match value { RegistryDecodedValue::String { .. } => value_type == 1, @@ -643,6 +779,73 @@ pub struct CollectionOutcome { pub observations: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotMetadata { + pub systemdiff_version: String, + pub captured_at: String, + pub host: HostMetadata, + pub privilege: PrivilegeState, + pub redaction: RedactionMetadata, +} + +pub fn assemble_snapshot( + metadata: SnapshotMetadata, + mut outcomes: Vec, +) -> Result { + outcomes.sort_by(|left, right| left.run.id.cmp(&right.run.id)); + for outcome in &mut outcomes { + outcome + .run + .coverage + .sort_by(|left, right| left.scope_id.cmp(&right.scope_id)); + outcome.run.diagnostics.sort_by(|left, right| { + ( + &left.scope_id, + &left.code, + &left.stage, + left.native_code, + &left.message, + ) + .cmp(&( + &right.scope_id, + &right.code, + &right.stage, + right.native_code, + &right.message, + )) + }); + outcome + .observations + .sort_by_key(|observation| observation.key()); + } + + let enabled_collectors = outcomes + .iter() + .map(|outcome| outcome.run.id.clone()) + .collect(); + let collectors = outcomes.iter().map(|outcome| outcome.run.clone()).collect(); + let mut observations: Vec<_> = outcomes + .into_iter() + .flat_map(|outcome| outcome.observations) + .collect(); + observations.sort_by_key(|observation| observation.key()); + + let snapshot = Snapshot { + document_type: SNAPSHOT_DOCUMENT_TYPE.to_owned(), + schema_version: SNAPSHOT_SCHEMA_VERSION, + systemdiff_version: metadata.systemdiff_version, + captured_at: metadata.captured_at, + host: metadata.host, + privilege: metadata.privilege, + enabled_collectors, + collectors, + redaction: metadata.redaction, + observations, + }; + snapshot.validate()?; + Ok(snapshot) +} + pub trait Collector { fn descriptor(&self) -> CollectorDescriptor; fn collect(&self, context: &CollectionContext) -> CollectionOutcome; @@ -713,6 +916,10 @@ pub enum SnapshotValidationError { collector_id: String, scope_id: String, }, + DiagnosticReferencesUnknownScope { + collector_id: String, + scope_id: String, + }, MissingCoverage { collector_id: String, scope_id: String, @@ -785,6 +992,13 @@ impl fmt::Display for SnapshotValidationError { formatter, "duplicate coverage scope for {collector_id}: {scope_id}" ), + Self::DiagnosticReferencesUnknownScope { + collector_id, + scope_id, + } => write!( + formatter, + "diagnostic references unknown coverage scope for {collector_id}: {scope_id}" + ), Self::MissingCoverage { collector_id, scope_id, diff --git a/crates/systemdiff-core/tests/registry_startup.rs b/crates/systemdiff-core/tests/registry_startup.rs index 7489930..e1b90c5 100644 --- a/crates/systemdiff-core/tests/registry_startup.rs +++ b/crates/systemdiff-core/tests/registry_startup.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use systemdiff_core::{ - Artifact, RegistryStartupEntry, RegistryStartupKind, RegistryView, RunOncePrefixSemantics, - Snapshot, SnapshotValidationError, + Artifact, RegistryStartupEntry, RegistryStartupKind, RegistryValueName, RegistryView, + RunOncePrefixSemantics, Snapshot, SnapshotValidationError, }; fn before_snapshot() -> Snapshot { @@ -83,7 +83,7 @@ fn documented_run_once_prefixes_validate_and_round_trip() { let mut snapshot = before_snapshot(); let entry = registry_entry(&mut snapshot); entry.key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce".to_owned(); - entry.value_name = value_name.to_owned(); + entry.value_name = RegistryValueName::decoded(value_name); entry.startup_kind = RegistryStartupKind::RunOnce; entry.run_once_prefix = Some(semantics); @@ -102,7 +102,7 @@ fn undocumented_run_once_prefix_forms_remain_uninterpreted() { let mut snapshot = before_snapshot(); let entry = registry_entry(&mut snapshot); entry.key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce".to_owned(); - entry.value_name = value_name.to_owned(); + entry.value_name = RegistryValueName::decoded(value_name); entry.startup_kind = RegistryStartupKind::RunOnce; entry.run_once_prefix = Some(RunOncePrefixSemantics::Undocumented); @@ -110,8 +110,8 @@ fn undocumented_run_once_prefix_forms_remain_uninterpreted() { .validate() .unwrap_or_else(|error| panic!("{value_name} must remain valid raw evidence: {error}")); assert_eq!( - registry_entry(&mut snapshot).value_name.as_str(), - value_name + registry_entry(&mut snapshot).value_name.decoded_value(), + Some(value_name) ); } } @@ -132,7 +132,7 @@ fn inconsistent_run_once_evidence_is_rejected() { let mut snapshot = before_snapshot(); let entry = registry_entry(&mut snapshot); entry.key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce".to_owned(); - entry.value_name = value_name.to_owned(); + entry.value_name = RegistryValueName::decoded(value_name); entry.startup_kind = RegistryStartupKind::RunOnce; entry.run_once_prefix = semantics; @@ -175,21 +175,30 @@ fn full_run_once_value_names_keep_distinct_observation_identities() { let template = snapshot.observations.remove(template_index); let cases = [ - ("Foo", RunOncePrefixSemantics::NoDocumentedPrefix), - ("!Foo", RunOncePrefixSemantics::DeferDeletionUntilAfterRun), - ("*Foo", RunOncePrefixSemantics::RunInSafeMode), + ( + "Foo", + RunOncePrefixSemantics::NoDocumentedPrefix, + "e300b1f49c3d61d973561e229a5b174ff27312a0df7c72801f6db2e8bd256a9e", + ), + ( + "!Foo", + RunOncePrefixSemantics::DeferDeletionUntilAfterRun, + "246bb80ad302e1d428b58825421b6ec88d372e0e7d68dcf60185332d7607d833", + ), + ( + "*Foo", + RunOncePrefixSemantics::RunInSafeMode, + "23c891afd1729eb817401b675d713aea7b9acfa5e9be7103a3c3e522c7edec94", + ), ]; - for (value_name, semantics) in cases { + for (value_name, semantics, canonical_id) in cases { let mut observation = template.clone(); - observation.canonical_id = format!( - "hkcu|shared|software\\microsoft\\windows\\currentversion\\runonce|{}", - value_name.to_ascii_lowercase() - ); + observation.canonical_id = canonical_id.to_owned(); let Artifact::RegistryStartup(entry) = &mut observation.artifact else { unreachable!("template must contain Registry evidence"); }; entry.key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce".to_owned(); - entry.value_name = value_name.to_owned(); + entry.value_name = RegistryValueName::decoded(value_name); entry.startup_kind = RegistryStartupKind::RunOnce; entry.run_once_prefix = Some(semantics); snapshot.observations.push(observation); @@ -205,7 +214,46 @@ fn full_run_once_value_names_keep_distinct_observation_identities() { .map(|observation| observation.key()) .collect(); assert_eq!(keys.len(), 3); - assert!(keys.iter().any(|key| key.canonical_id.ends_with("|foo"))); - assert!(keys.iter().any(|key| key.canonical_id.ends_with("|!foo"))); - assert!(keys.iter().any(|key| key.canonical_id.ends_with("|*foo"))); + for expected in cases.map(|(_, _, canonical_id)| canonical_id) { + assert!(keys.iter().any(|key| key.canonical_id == expected)); + } +} + +#[test] +fn unnamed_value_is_stable_evidence_not_marker_corruption() { + let mut snapshot = before_snapshot(); + let entry = registry_entry(&mut snapshot); + entry.key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce".to_owned(); + entry.value_name = RegistryValueName::decoded(""); + entry.startup_kind = RegistryStartupKind::RunOnce; + entry.run_once_prefix = Some(RunOncePrefixSemantics::NoDocumentedPrefix); + + snapshot + .validate() + .expect("the unnamed value must remain valid evidence"); + let json = serde_json::to_string(&snapshot).expect("Snapshot must serialize"); + let reparsed: Snapshot = serde_json::from_str(&json).expect("Snapshot must deserialize"); + assert_eq!(reparsed, snapshot); +} + +#[test] +fn invalid_utf16_value_name_round_trips_losslessly() { + let units = [u16::from(b'!'), 0xd800, u16::from(b'X')]; + let name = RegistryValueName::from_utf16_units(&units); + assert!(matches!(name, RegistryValueName::InvalidUtf16 { .. })); + assert_eq!(name.utf16_units().as_deref(), Some(units.as_slice())); + + let mut snapshot = before_snapshot(); + let entry = registry_entry(&mut snapshot); + entry.key_path = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce".to_owned(); + entry.value_name = name; + entry.startup_kind = RegistryStartupKind::RunOnce; + entry.run_once_prefix = Some(RunOncePrefixSemantics::DeferDeletionUntilAfterRun); + snapshot + .validate() + .expect("invalid UTF-16 name evidence must remain lossless"); + + let json = serde_json::to_string(&snapshot).expect("Snapshot must serialize"); + let reparsed: Snapshot = serde_json::from_str(&json).expect("Snapshot must deserialize"); + assert_eq!(reparsed, snapshot); } diff --git a/crates/systemdiff-core/tests/snapshot_fixture.rs b/crates/systemdiff-core/tests/snapshot_fixture.rs index f500529..80d723d 100644 --- a/crates/systemdiff-core/tests/snapshot_fixture.rs +++ b/crates/systemdiff-core/tests/snapshot_fixture.rs @@ -1,7 +1,8 @@ use systemdiff_core::{ - Artifact, CollectorStatus, REGISTRY_RAW_EVIDENCE_MAX_CAPTURE_BYTES, RegistryDecodedValue, - RegistryHive, RegistryRawEvidence, RegistryStartupEntry, RegistryStartupKind, - RegistryValueDecoding, RegistryView, Snapshot, SnapshotValidationError, + Artifact, CollectionOutcome, CollectorStatus, REGISTRY_RAW_EVIDENCE_MAX_CAPTURE_BYTES, + RegistryDecodedValue, RegistryHive, RegistryRawEvidence, RegistryStartupEntry, + RegistryStartupKind, RegistryValueDecoding, RegistryValueName, RegistryView, Snapshot, + SnapshotMetadata, SnapshotValidationError, assemble_snapshot, }; fn before_snapshot() -> Snapshot { @@ -20,6 +21,49 @@ fn draft_v1_fixture_validates_and_round_trips() { assert_eq!(reparsed, snapshot); } +#[test] +fn snapshot_assembly_is_deterministic_across_collector_and_observation_order() { + let source = before_snapshot(); + let outcomes: Vec<_> = source + .collectors + .iter() + .cloned() + .map(|run| { + let observations: Vec<_> = source + .observations + .iter() + .filter(|observation| observation.collector_id == run.id) + .cloned() + .collect(); + CollectionOutcome { run, observations } + }) + .collect(); + let mut shuffled = outcomes.clone(); + shuffled.reverse(); + for outcome in &mut shuffled { + outcome.run.coverage.reverse(); + outcome.run.diagnostics.reverse(); + outcome.observations.reverse(); + } + let metadata = SnapshotMetadata { + systemdiff_version: source.systemdiff_version.clone(), + captured_at: source.captured_at.clone(), + host: source.host.clone(), + privilege: source.privilege, + redaction: source.redaction.clone(), + }; + let assembled = assemble_snapshot(metadata.clone(), outcomes) + .expect("fixture outcomes must assemble into a valid Snapshot"); + let shuffled = assemble_snapshot(metadata, shuffled) + .expect("shuffled fixture outcomes must assemble into a valid Snapshot"); + + assert_eq!(assembled, shuffled); + assert_eq!( + serde_json::to_string_pretty(&assembled).expect("assembled Snapshot must serialize"), + serde_json::to_string_pretty(&shuffled).expect("shuffled Snapshot must serialize") + ); +} + #[test] fn captured_at_accepts_supported_utc_rfc3339_forms() { for captured_at in [ @@ -130,7 +174,7 @@ fn registry_decoding_round_trips_typed_values_and_compact_raw_evidence() { hive: RegistryHive::CurrentUser, registry_view: RegistryView::Shared, key_path: "Software\\Microsoft\\Windows\\CurrentVersion\\Run".to_owned(), - value_name: "Synthetic".to_owned(), + value_name: RegistryValueName::decoded("Synthetic"), startup_kind: RegistryStartupKind::Run, run_once_prefix: None, value_type, @@ -151,7 +195,7 @@ fn registry_decoding_round_trips_typed_values_and_compact_raw_evidence() { hive: RegistryHive::CurrentUser, registry_view: RegistryView::Shared, key_path: "Software\\Microsoft\\Windows\\CurrentVersion\\Run".to_owned(), - value_name: "Synthetic".to_owned(), + value_name: RegistryValueName::decoded("Synthetic"), startup_kind: RegistryStartupKind::Run, run_once_prefix: None, value_type: 4, @@ -279,3 +323,37 @@ fn unavailable_scope_cannot_emit_observations_from_partial_collector() { Err(SnapshotValidationError::ObservationFromUnavailableScope { .. }) )); } + +#[test] +fn scoped_diagnostic_must_reference_collector_coverage() { + let mut snapshot = before_snapshot(); + let tasks = snapshot + .collectors + .iter_mut() + .find(|run| run.id == "windows.scheduled_tasks") + .expect("scheduled tasks run must exist"); + tasks.diagnostics[0].scope_id = Some("unknown.scope".to_owned()); + + assert!(matches!( + snapshot.validate(), + Err(SnapshotValidationError::DiagnosticReferencesUnknownScope { .. }) + )); +} + +#[test] +fn missing_diagnostic_scope_remains_a_valid_collector_wide_diagnostic() { + let mut document: serde_json::Value = + serde_json::from_str(include_str!("../../../fixtures/snapshots/before-v1.json")) + .expect("fixture JSON must deserialize"); + document["collectors"][2]["diagnostics"][0] + .as_object_mut() + .expect("diagnostic must be an object") + .remove("scope_id"); + + let snapshot: Snapshot = serde_json::from_value(document) + .expect("missing optional scope_id must remain readable as collector-wide"); + snapshot + .validate() + .expect("collector-wide diagnostic must remain valid"); + assert_eq!(snapshot.collectors[2].diagnostics[0].scope_id, None); +} diff --git a/crates/systemdiff-diff/tests/snapshot_diff.rs b/crates/systemdiff-diff/tests/snapshot_diff.rs index fd18cdc..b199c94 100644 --- a/crates/systemdiff-diff/tests/snapshot_diff.rs +++ b/crates/systemdiff-diff/tests/snapshot_diff.rs @@ -9,6 +9,18 @@ fn snapshots() -> (Snapshot, Snapshot) { (before, after) } +fn registry_snapshots() -> (Snapshot, Snapshot) { + let before = serde_json::from_str(include_str!( + "../../../fixtures/snapshots/registry-before-v1.json" + )) + .expect("the Registry before fixture must deserialize"); + let after = serde_json::from_str(include_str!( + "../../../fixtures/snapshots/registry-after-v1.json" + )) + .expect("the Registry after fixture must deserialize"); + (before, after) +} + #[test] fn classifies_added_removed_modified_and_inconclusive_changes() { let (before, after) = snapshots(); @@ -211,3 +223,53 @@ fn overlapping_collector_version_mismatch_is_rejected() { Err(DiffError::IncompatibleCollectorVersion { .. }) )); } + +#[test] +fn registry_fixture_produces_exactly_one_added_startup_value() { + let (before, after) = registry_snapshots(); + let diff = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("Registry fixtures must be comparable"); + + assert!(diff.warnings.is_empty()); + assert_eq!(diff.changes.len(), 1); + assert_eq!(diff.changes[0].key.collector_id, "windows.registry.startup"); + assert_eq!(diff.changes[0].key.scope_id, "current_user.shared.run"); + assert!(matches!( + &diff.changes[0].change, + ChangeKind::Added { + after: Artifact::RegistryStartup(_) + } + )); +} + +#[test] +fn partial_registry_scope_does_not_create_a_false_removal() { + let (_, before) = registry_snapshots(); + let (mut after, _) = registry_snapshots(); + after.captured_at = "2026-08-11T00:02:00Z".to_owned(); + let registry = after + .collectors + .iter_mut() + .find(|run| run.id == "windows.registry.startup") + .expect("Registry run must exist"); + registry.status = CollectorStatus::Partial; + registry + .coverage + .iter_mut() + .find(|coverage| coverage.scope_id == "current_user.shared.run") + .expect("HKCU Run scope must exist") + .status = CollectorStatus::Partial; + + let diff = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("partial Registry coverage must produce a conservative diff"); + assert_eq!(diff.changes.len(), 1); + assert!(matches!( + &diff.changes[0].change, + ChangeKind::Inconclusive { .. } + )); + assert!( + diff.warnings + .iter() + .any(|warning| warning.scope_id == "current_user.shared.run") + ); +} diff --git a/crates/systemdiff-risk/src/lib.rs b/crates/systemdiff-risk/src/lib.rs index 36479f9..c45d541 100644 --- a/crates/systemdiff-risk/src/lib.rs +++ b/crates/systemdiff-risk/src/lib.rs @@ -86,16 +86,17 @@ mod tests { change_id: "change:v1:synthetic".to_owned(), key: ArtifactKey { collector_id: "windows.registry.startup".to_owned(), - scope_id: "current_user.shared".to_owned(), + scope_id: "current_user.shared.run".to_owned(), artifact_kind: "registry_startup".to_owned(), - canonical_id: "synthetic".to_owned(), + canonical_id: "2b5aa4b6759d1a597bfe4cbaee815e29975512901f60b05d331e26296ed67ebb" + .to_owned(), }, change: ChangeKind::Added { after: systemdiff_core::Artifact::RegistryStartup(RegistryStartupEntry { hive: RegistryHive::CurrentUser, registry_view: RegistryView::Shared, key_path: "Software\\Microsoft\\Windows\\CurrentVersion\\Run".to_owned(), - value_name: "Synthetic".to_owned(), + value_name: systemdiff_core::RegistryValueName::decoded("Synthetic"), startup_kind: RegistryStartupKind::Run, run_once_prefix: None, value_type: 1, diff --git a/crates/systemdiff-windows/Cargo.toml b/crates/systemdiff-windows/Cargo.toml index 589c5b7..cabf225 100644 --- a/crates/systemdiff-windows/Cargo.toml +++ b/crates/systemdiff-windows/Cargo.toml @@ -7,4 +7,15 @@ authors.workspace = true publish = false [dependencies] +sha2.workspace = true systemdiff-core.workspace = true + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Registry", + "Win32_System_SystemInformation", + "Win32_System_Threading", +] } +windows-version.workspace = true diff --git a/crates/systemdiff-windows/src/lib.rs b/crates/systemdiff-windows/src/lib.rs index a132b7a..5c4be4b 100644 --- a/crates/systemdiff-windows/src/lib.rs +++ b/crates/systemdiff-windows/src/lib.rs @@ -1,8 +1,23 @@ #![deny(unsafe_op_in_unsafe_fn)] +mod platform; +mod registry; +mod win32; + +use std::error::Error; +use std::fmt; +use systemdiff_core::{ + CollectionContext, Collector, RedactionMetadata, RedactionStatus, Snapshot, SnapshotMetadata, + SnapshotValidationError, assemble_snapshot, +}; + +pub use registry::{ + MAX_REGISTRY_COLLECTOR_EVIDENCE_BYTES, MAX_REGISTRY_VALUE_DATA_BYTES, + MAX_REGISTRY_VALUES_PER_SCOPE, REGISTRY_STARTUP_COLLECTOR_ID, + REGISTRY_STARTUP_COLLECTOR_VERSION, RegistryStartupCollector, +}; use systemdiff_core::{CollectorDescriptor, PrivilegeRequirement}; -pub const REGISTRY_STARTUP_COLLECTOR_ID: &str = "windows.registry.startup"; pub const SERVICES_COLLECTOR_ID: &str = "windows.services"; pub const SCHEDULED_TASKS_COLLECTOR_ID: &str = "windows.scheduled_tasks"; @@ -14,19 +29,15 @@ pub struct CollectorPlan { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ImplementationStatus { + Implemented, Planned, } pub fn mvp_collector_plans() -> Vec { vec![ CollectorPlan { - descriptor: CollectorDescriptor { - id: REGISTRY_STARTUP_COLLECTOR_ID.to_owned(), - version: 1, - description: "Documented Run and RunOnce registry startup locations.".to_owned(), - privilege: PrivilegeRequirement::StandardUserPartial, - }, - implementation: ImplementationStatus::Planned, + descriptor: registry::descriptor(), + implementation: ImplementationStatus::Implemented, }, CollectorPlan { descriptor: CollectorDescriptor { @@ -50,13 +61,66 @@ pub fn mvp_collector_plans() -> Vec { ] } +pub fn capture_snapshot( + captured_at: String, + systemdiff_version: String, +) -> Result { + if !platform::is_supported() { + return Err(CaptureError::UnsupportedPlatform); + } + let host = platform::host_metadata(); + let privilege = platform::privilege_state(); + let context = CollectionContext { privilege }; + let outcome = RegistryStartupCollector.collect(&context); + assemble_snapshot( + SnapshotMetadata { + systemdiff_version, + captured_at, + host, + privilege, + redaction: RedactionMetadata { + status: RedactionStatus::Unredacted, + policy: None, + }, + }, + vec![outcome], + ) + .map_err(CaptureError::InvalidSnapshot) +} + +#[derive(Debug)] +pub enum CaptureError { + UnsupportedPlatform, + InvalidSnapshot(SnapshotValidationError), +} + +impl fmt::Display for CaptureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedPlatform => formatter.write_str( + "snapshot collection requires Windows 10 version 1709 / Windows Server 2016 version 1709 or later", + ), + Self::InvalidSnapshot(error) => write!(formatter, "collected Snapshot is invalid: {error}"), + } + } +} + +impl Error for CaptureError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::UnsupportedPlatform => None, + Self::InvalidSnapshot(error) => Some(error), + } + } +} + #[cfg(test)] mod tests { use super::*; use std::collections::BTreeSet; #[test] - fn planned_collector_ids_are_unique_and_versioned() { + fn collector_ids_are_unique_versioned_and_registry_is_implemented() { let plans = mvp_collector_plans(); let ids: BTreeSet<_> = plans .iter() @@ -65,8 +129,9 @@ mod tests { assert_eq!(plans.len(), ids.len()); assert!(plans.iter().all(|plan| plan.descriptor.version > 0)); + assert_eq!(plans[0].implementation, ImplementationStatus::Implemented); assert!( - plans + plans[1..] .iter() .all(|plan| plan.implementation == ImplementationStatus::Planned) ); diff --git a/crates/systemdiff-windows/src/platform.rs b/crates/systemdiff-windows/src/platform.rs new file mode 100644 index 0000000..cde5ae8 --- /dev/null +++ b/crates/systemdiff-windows/src/platform.rs @@ -0,0 +1,234 @@ +use systemdiff_core::{HostMetadata, PrivilegeState}; + +#[cfg(any(windows, test))] +fn classify_privilege_state( + is_local_system: Option, + is_elevated: Option, +) -> PrivilegeState { + match is_local_system { + Some(true) => PrivilegeState::System, + Some(false) => match is_elevated { + Some(true) => PrivilegeState::Elevated, + Some(false) => PrivilegeState::StandardUser, + None => PrivilegeState::Unknown, + }, + None => PrivilegeState::Unknown, + } +} + +#[cfg(windows)] +pub(crate) fn is_supported() -> bool { + windows_version::OsVersion::current() >= windows_version::OsVersion::new(10, 0, 0, 16_299) +} + +#[cfg(not(windows))] +pub(crate) fn is_supported() -> bool { + false +} + +#[cfg(windows)] +pub(crate) fn host_metadata() -> HostMetadata { + use windows::Win32::System::SystemInformation::{ + IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_I386, + IMAGE_FILE_MACHINE_UNKNOWN, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2}; + + let version = windows_version::OsVersion::current(); + let product = if windows_version::is_server() { + "Windows Server" + } else { + "Windows" + }; + let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; + let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + // SAFETY: the pseudo-handle is valid for the current process and both + // output pointers refer to initialized local storage for the call. + let architecture = unsafe { + IsWow64Process2( + GetCurrentProcess(), + &mut process_machine, + Some(&mut native_machine), + ) + } + .ok() + .and_then(|()| { + if native_machine == IMAGE_FILE_MACHINE_AMD64 { + Some("x86_64") + } else if native_machine == IMAGE_FILE_MACHINE_I386 { + Some("x86") + } else if native_machine == IMAGE_FILE_MACHINE_ARM64 { + Some("arm64") + } else { + None + } + }) + .map(str::to_owned); + + HostMetadata { + windows_version: Some(format!("{product} {}.{}", version.major, version.minor)), + windows_build: Some(version.build.to_string()), + architecture, + } +} + +#[cfg(not(windows))] +pub(crate) fn host_metadata() -> HostMetadata { + HostMetadata { + windows_version: None, + windows_build: None, + architecture: None, + } +} + +#[cfg(windows)] +pub(crate) fn privilege_state() -> PrivilegeState { + use std::mem::size_of; + use windows::Win32::Foundation::{CloseHandle, ERROR_INSUFFICIENT_BUFFER, HANDLE, WIN32_ERROR}; + use windows::Win32::Security::{ + GetTokenInformation, IsValidSid, IsWellKnownSid, TOKEN_ELEVATION, TOKEN_QUERY, TOKEN_USER, + TokenElevation, TokenUser, WinLocalSystemSid, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + struct OwnedToken(HANDLE); + impl Drop for OwnedToken { + fn drop(&mut self) { + // SAFETY: the handle is owned after a successful OpenProcessToken. + let _ = unsafe { CloseHandle(self.0) }; + } + } + + let mut token = HANDLE::default(); + // SAFETY: token is a valid out pointer and the current-process pseudo-handle + // is valid. The requested access is query-only. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() { + return PrivilegeState::Unknown; + } + if token.is_invalid() { + return PrivilegeState::Unknown; + } + let token = OwnedToken(token); + let is_local_system = token_is_local_system(token.0); + if is_local_system != Some(false) { + return classify_privilege_state(is_local_system, None); + } + return classify_privilege_state(is_local_system, token_is_elevated(token.0)); + + fn token_is_local_system(token: HANDLE) -> Option { + const MAX_TOKEN_USER_BYTES: usize = 64 * 1024; + + let mut required = 0_u32; + // SAFETY: the token is open for TOKEN_QUERY, the null buffer is the + // documented size probe, and required is a valid output pointer. + let probe = unsafe { GetTokenInformation(token, TokenUser, None, 0, &mut required) }; + match probe { + Err(error) if WIN32_ERROR::from_error(&error) == Some(ERROR_INSUFFICIENT_BUFFER) => {} + _ => return None, + } + + let required = usize::try_from(required).ok()?; + if required < size_of::() || required > MAX_TOKEN_USER_BYTES { + return None; + } + let words = required + .checked_add(size_of::() - 1)? + .checked_div(size_of::())?; + let mut buffer = vec![0_usize; words]; + let required_u32 = u32::try_from(required).ok()?; + let mut returned = 0_u32; + // SAFETY: Vec provides alignment suitable for TOKEN_USER, its + // initialized allocation is at least required bytes, and the token is + // queryable. The returned length is validated before the typed read. + if unsafe { + GetTokenInformation( + token, + TokenUser, + Some(buffer.as_mut_ptr().cast()), + required_u32, + &mut returned, + ) + } + .is_err() + { + return None; + } + let returned = usize::try_from(returned).ok()?; + if returned < size_of::() || returned > required { + return None; + } + + // SAFETY: the buffer has TOKEN_USER alignment and the successful API + // call returned at least size_of::() initialized bytes. + let token_user = unsafe { &*buffer.as_ptr().cast::() }; + let sid = token_user.User.Sid; + if sid.is_invalid() { + return None; + } + // SAFETY: SID is borrowed from the still-live TokenUser buffer returned + // by Windows. IsValidSid checks its documented structure before the + // well-known SID comparison. + if !unsafe { IsValidSid(sid) }.as_bool() { + return None; + } + // SAFETY: sid remains valid for the call and passed IsValidSid above. + Some(unsafe { IsWellKnownSid(sid, WinLocalSystemSid) }.as_bool()) + } + + fn token_is_elevated(token: HANDLE) -> Option { + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0_u32; + let length = u32::try_from(size_of::()).ok()?; + // SAFETY: token is queryable, the output pointer references a correctly + // sized TOKEN_ELEVATION, and returned receives the documented byte count. + if unsafe { + GetTokenInformation( + token, + TokenElevation, + Some((&mut elevation as *mut TOKEN_ELEVATION).cast()), + length, + &mut returned, + ) + } + .is_err() + || returned < length + { + return None; + } + Some(elevation.TokenIsElevated != 0) + } +} + +#[cfg(not(windows))] +pub(crate) fn privilege_state() -> PrivilegeState { + PrivilegeState::Unknown +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn privilege_classification_prioritizes_local_system_and_propagates_unknowns() { + assert_eq!( + classify_privilege_state(Some(true), Some(false)), + PrivilegeState::System + ); + assert_eq!( + classify_privilege_state(Some(false), Some(true)), + PrivilegeState::Elevated + ); + assert_eq!( + classify_privilege_state(Some(false), Some(false)), + PrivilegeState::StandardUser + ); + assert_eq!( + classify_privilege_state(None, Some(true)), + PrivilegeState::Unknown + ); + assert_eq!( + classify_privilege_state(Some(false), None), + PrivilegeState::Unknown + ); + } +} diff --git a/crates/systemdiff-windows/src/registry.rs b/crates/systemdiff-windows/src/registry.rs new file mode 100644 index 0000000..4cb890c --- /dev/null +++ b/crates/systemdiff-windows/src/registry.rs @@ -0,0 +1,1286 @@ +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::mem::size_of; +use systemdiff_core::{ + Artifact, CollectionContext, CollectionOutcome, Collector, CollectorDescriptor, CollectorRun, + CollectorStatus, Diagnostic, Observation, PrivilegeRequirement, RegistryDecodedValue, + RegistryHive, RegistryStartupEntry, RegistryStartupKind, RegistryValueDecoding, + RegistryValueName, RegistryView, ScopeCoverage, classify_run_once_prefix_units, +}; + +pub const REGISTRY_STARTUP_COLLECTOR_ID: &str = "windows.registry.startup"; +pub const REGISTRY_STARTUP_COLLECTOR_VERSION: u32 = 1; +pub const MAX_REGISTRY_VALUE_DATA_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_REGISTRY_COLLECTOR_EVIDENCE_BYTES: usize = 32 * 1024 * 1024; +pub const MAX_REGISTRY_VALUES_PER_SCOPE: u32 = 4_096; +pub const MAX_SCOPE_ATTEMPTS: usize = 3; + +const RUN_KEY_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +const RUN_ONCE_KEY_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce"; +const IDENTITY_DOMAIN: &[u8] = b"systemdiff.registry-startup.identity.v1\0"; + +pub fn descriptor() -> CollectorDescriptor { + CollectorDescriptor { + id: REGISTRY_STARTUP_COLLECTOR_ID.to_owned(), + version: REGISTRY_STARTUP_COLLECTOR_VERSION, + description: "Documented Run and RunOnce registry startup locations.".to_owned(), + privilege: PrivilegeRequirement::StandardUserPartial, + } +} + +pub struct RegistryStartupCollector; + +impl Collector for RegistryStartupCollector { + fn descriptor(&self) -> CollectorDescriptor { + descriptor() + } + + fn collect(&self, context: &CollectionContext) -> CollectionOutcome { + let mut source = crate::win32::Win32RegistrySource::new(); + collect_with_source(&mut source, context) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) enum RegistryLayout { + X64, + X86, + Arm64, + Unknown(u16), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RegistryRoot { + CurrentUser, + LocalMachine, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ViewSelector { + Shared, + Native, + Registry32, + Registry64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RegistryTarget { + pub scope_id: &'static str, + pub root: RegistryRoot, + pub view: RegistryView, + pub selector: ViewSelector, + pub key_path: &'static str, + pub startup_kind: RegistryStartupKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct KeyMetadata { + pub value_count: u32, + pub last_write: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RawRegistryValue { + pub name_utf16: Vec, + pub value_type: u32, + pub data: Vec, +} + +pub(crate) fn native_evidence_bytes(record: &RawRegistryValue) -> Option { + record + .name_utf16 + .len() + .checked_mul(size_of::())? + .checked_add(record.data.len()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct KeyReadAttempt { + pub before: KeyMetadata, + pub after: KeyMetadata, + pub records: Vec, + pub issues: Vec, + pub stable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(any(windows, test)), allow(dead_code))] +pub(crate) enum ReadKeyResult { + Missing, + Present(KeyReadAttempt), + Failed(SourceFailure), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SourceFailureKind { + AccessDenied, + KeyDeleted, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceFailure { + pub kind: SourceFailureKind, + pub stage: &'static str, + pub native_code: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceIssue { + pub code: &'static str, + pub message: &'static str, + pub stage: &'static str, + pub native_code: Option, +} + +pub(crate) fn registry_resource_limit_issue() -> SourceIssue { + SourceIssue { + code: "registry_resource_limit", + message: "Registry name and value evidence exceeded the remaining SystemDiff Collector budget.", + stage: "enumerate", + native_code: None, + } +} + +pub(crate) trait RegistryDataSource { + fn detect_layout(&mut self) -> Result; + + fn read_key_once( + &mut self, + target: &RegistryTarget, + available_evidence_bytes: usize, + ) -> ReadKeyResult; +} + +fn collect_with_source( + source: &mut S, + context: &CollectionContext, +) -> CollectionOutcome { + collect_with_source_and_budget(source, context, MAX_REGISTRY_COLLECTOR_EVIDENCE_BYTES) +} + +fn collect_with_source_and_budget( + source: &mut S, + _context: &CollectionContext, + collector_evidence_budget: usize, +) -> CollectionOutcome { + let (targets, mut precomputed_scopes) = match source.detect_layout() { + Ok(layout) => targets_for_layout(layout), + Err(failure) => targets_for_layout_failure(failure), + }; + + let mut remaining_evidence_bytes = collector_evidence_budget; + let mut scopes = Vec::new(); + let mut observations = Vec::new(); + + for target in targets { + let result = collect_target(source, &target, remaining_evidence_bytes); + remaining_evidence_bytes = + remaining_evidence_bytes.saturating_sub(result.retained_evidence_bytes); + scopes.push(result.scope); + observations.extend(result.observations); + } + scopes.append(&mut precomputed_scopes); + scopes.sort_by(|left, right| left.coverage.scope_id.cmp(&right.coverage.scope_id)); + observations.sort_by_key(|observation| observation.key()); + + let status = aggregate_status(scopes.iter().map(|scope| scope.coverage.status)); + let coverage = scopes.iter().map(|scope| scope.coverage.clone()).collect(); + let mut diagnostics: Vec<_> = scopes + .into_iter() + .flat_map(|scope| scope.diagnostics) + .collect(); + diagnostics.sort_by(|left, right| { + (&left.scope_id, &left.code, &left.stage, left.native_code).cmp(&( + &right.scope_id, + &right.code, + &right.stage, + right.native_code, + )) + }); + + CollectionOutcome { + run: CollectorRun { + id: REGISTRY_STARTUP_COLLECTOR_ID.to_owned(), + version: REGISTRY_STARTUP_COLLECTOR_VERSION, + status, + coverage, + diagnostics, + }, + observations, + } +} + +struct TargetCollection { + scope: ScopeResult, + observations: Vec, + retained_evidence_bytes: usize, +} + +fn collect_target( + source: &mut S, + target: &RegistryTarget, + available_evidence_bytes: usize, +) -> TargetCollection { + let mut last_attempt = None; + let mut last_key_deleted = None; + + for attempt_index in 0..MAX_SCOPE_ATTEMPTS { + match source.read_key_once(target, available_evidence_bytes) { + ReadKeyResult::Missing => { + return TargetCollection::empty(target.scope_id, CollectorStatus::Complete); + } + ReadKeyResult::Failed(failure) + if failure.kind == SourceFailureKind::KeyDeleted + && attempt_index + 1 < MAX_SCOPE_ATTEMPTS => + { + last_key_deleted = Some(failure); + } + ReadKeyResult::Failed(failure) if failure.kind == SourceFailureKind::KeyDeleted => { + last_key_deleted = Some(failure); + break; + } + ReadKeyResult::Failed(failure) => { + let status = if failure.kind == SourceFailureKind::AccessDenied { + CollectorStatus::PermissionDenied + } else { + CollectorStatus::Failed + }; + return TargetCollection { + scope: ScopeResult { + coverage: ScopeCoverage { + scope_id: target.scope_id.to_owned(), + status, + }, + diagnostics: vec![diagnostic_from_failure(target.scope_id, &failure)], + }, + observations: Vec::new(), + retained_evidence_bytes: 0, + }; + } + ReadKeyResult::Present(attempt) if attempt.stable => { + return build_target_collection(target, attempt, available_evidence_bytes); + } + ReadKeyResult::Present(attempt) => { + last_attempt = Some(attempt); + } + } + } + + if let Some(attempt) = last_attempt { + build_target_collection(target, attempt, available_evidence_bytes) + } else { + let failure = last_key_deleted.unwrap_or(SourceFailure { + kind: SourceFailureKind::KeyDeleted, + stage: "enumerate", + native_code: 1018, + }); + TargetCollection { + scope: ScopeResult { + coverage: ScopeCoverage { + scope_id: target.scope_id.to_owned(), + status: CollectorStatus::Partial, + }, + diagnostics: vec![Diagnostic { + code: "registry_changed_during_scan".to_owned(), + message: "The Registry key changed during bounded collection retries." + .to_owned(), + stage: Some(failure.stage.to_owned()), + native_code: Some(failure.native_code), + scope_id: Some(target.scope_id.to_owned()), + }], + }, + observations: Vec::new(), + retained_evidence_bytes: 0, + } + } +} + +fn build_target_collection( + target: &RegistryTarget, + attempt: KeyReadAttempt, + available_evidence_bytes: usize, +) -> TargetCollection { + let KeyReadAttempt { + before, + after, + records, + mut issues, + stable, + } = attempt; + if !stable || before != after { + issues.push(SourceIssue { + code: "registry_changed_during_scan", + message: "The Registry key changed during bounded collection retries.", + stage: "enumerate", + native_code: None, + }); + } + let mut by_identity: BTreeMap> = BTreeMap::new(); + for record in records { + by_identity + .entry(registry_value_identity(&record.name_utf16)) + .or_default() + .push(record); + } + + let mut observations = Vec::new(); + let mut retained_evidence_bytes = 0_usize; + for (canonical_id, mut records) in by_identity { + if records.len() != 1 { + issues.push(SourceIssue { + code: "registry_identity_collision", + message: "Multiple Registry values produced one Collector identity.", + stage: "normalize", + native_code: None, + }); + continue; + } + let record = records.remove(0); + let Some(evidence_bytes) = native_evidence_bytes(&record) else { + issues.push(registry_resource_limit_issue()); + continue; + }; + let Some(next_retained) = retained_evidence_bytes.checked_add(evidence_bytes) else { + issues.push(registry_resource_limit_issue()); + continue; + }; + if next_retained > available_evidence_bytes { + issues.push(registry_resource_limit_issue()); + continue; + } + retained_evidence_bytes = next_retained; + observations.push(observation_from_raw(target, canonical_id, record)); + } + observations.sort_by_key(|observation| observation.key()); + issues.sort_by(|left, right| { + (left.code, left.stage, left.native_code, left.message).cmp(&( + right.code, + right.stage, + right.native_code, + right.message, + )) + }); + issues.dedup(); + + let status = if issues.is_empty() { + CollectorStatus::Complete + } else { + CollectorStatus::Partial + }; + let diagnostics = issues + .iter() + .map(|issue| Diagnostic { + code: issue.code.to_owned(), + message: issue.message.to_owned(), + stage: Some(issue.stage.to_owned()), + native_code: issue.native_code, + scope_id: Some(target.scope_id.to_owned()), + }) + .collect(); + TargetCollection { + scope: ScopeResult { + coverage: ScopeCoverage { + scope_id: target.scope_id.to_owned(), + status, + }, + diagnostics, + }, + observations, + retained_evidence_bytes, + } +} + +impl TargetCollection { + fn empty(scope_id: &str, status: CollectorStatus) -> Self { + Self { + scope: ScopeResult { + coverage: ScopeCoverage { + scope_id: scope_id.to_owned(), + status, + }, + diagnostics: Vec::new(), + }, + observations: Vec::new(), + retained_evidence_bytes: 0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ScopeResult { + coverage: ScopeCoverage, + diagnostics: Vec, +} + +fn targets_for_layout(layout: RegistryLayout) -> (Vec, Vec) { + let mut targets = current_user_targets(); + let precomputed = match layout { + RegistryLayout::X64 => { + targets.extend(local_machine_alternate_targets()); + Vec::new() + } + RegistryLayout::X86 => { + targets.extend(local_machine_native_targets()); + Vec::new() + } + RegistryLayout::Arm64 | RegistryLayout::Unknown(_) => unsupported_machine_scopes(), + }; + (targets, precomputed) +} + +fn targets_for_layout_failure(failure: SourceFailure) -> (Vec, Vec) { + let scopes = alternate_scope_ids() + .into_iter() + .map(|scope_id| ScopeResult { + coverage: ScopeCoverage { + scope_id: scope_id.to_owned(), + status: CollectorStatus::Failed, + }, + diagnostics: vec![Diagnostic { + code: "registry_layout_failed".to_owned(), + message: "Windows Registry view layout detection failed.".to_owned(), + stage: Some(failure.stage.to_owned()), + native_code: Some(failure.native_code), + scope_id: Some(scope_id.to_owned()), + }], + }) + .collect(); + (current_user_targets(), scopes) +} + +fn current_user_targets() -> Vec { + vec![ + target( + "current_user.shared.run", + RegistryRoot::CurrentUser, + RegistryView::Shared, + ViewSelector::Shared, + RUN_KEY_PATH, + RegistryStartupKind::Run, + ), + target( + "current_user.shared.run_once", + RegistryRoot::CurrentUser, + RegistryView::Shared, + ViewSelector::Shared, + RUN_ONCE_KEY_PATH, + RegistryStartupKind::RunOnce, + ), + ] +} + +fn local_machine_native_targets() -> Vec { + vec![ + target( + "local_machine.native.run", + RegistryRoot::LocalMachine, + RegistryView::Native, + ViewSelector::Native, + RUN_KEY_PATH, + RegistryStartupKind::Run, + ), + target( + "local_machine.native.run_once", + RegistryRoot::LocalMachine, + RegistryView::Native, + ViewSelector::Native, + RUN_ONCE_KEY_PATH, + RegistryStartupKind::RunOnce, + ), + ] +} + +fn local_machine_alternate_targets() -> Vec { + vec![ + target( + "local_machine.registry32.run", + RegistryRoot::LocalMachine, + RegistryView::Registry32, + ViewSelector::Registry32, + RUN_KEY_PATH, + RegistryStartupKind::Run, + ), + target( + "local_machine.registry32.run_once", + RegistryRoot::LocalMachine, + RegistryView::Registry32, + ViewSelector::Registry32, + RUN_ONCE_KEY_PATH, + RegistryStartupKind::RunOnce, + ), + target( + "local_machine.registry64.run", + RegistryRoot::LocalMachine, + RegistryView::Registry64, + ViewSelector::Registry64, + RUN_KEY_PATH, + RegistryStartupKind::Run, + ), + target( + "local_machine.registry64.run_once", + RegistryRoot::LocalMachine, + RegistryView::Registry64, + ViewSelector::Registry64, + RUN_ONCE_KEY_PATH, + RegistryStartupKind::RunOnce, + ), + ] +} + +fn alternate_scope_ids() -> [&'static str; 4] { + [ + "local_machine.registry32.run", + "local_machine.registry32.run_once", + "local_machine.registry64.run", + "local_machine.registry64.run_once", + ] +} + +fn unsupported_machine_scopes() -> Vec { + alternate_scope_ids() + .into_iter() + .map(|scope_id| ScopeResult { + coverage: ScopeCoverage { + scope_id: scope_id.to_owned(), + status: CollectorStatus::Unsupported, + }, + diagnostics: vec![Diagnostic { + code: "registry_layout_unsupported".to_owned(), + message: "This v1 Collector does not define HKLM alternate Registry views for the detected architecture.".to_owned(), + stage: Some("detect_layout".to_owned()), + native_code: None, + scope_id: Some(scope_id.to_owned()), + }], + }) + .collect() +} + +fn target( + scope_id: &'static str, + root: RegistryRoot, + view: RegistryView, + selector: ViewSelector, + key_path: &'static str, + startup_kind: RegistryStartupKind, +) -> RegistryTarget { + RegistryTarget { + scope_id, + root, + view, + selector, + key_path, + startup_kind, + } +} + +fn observation_from_raw( + target: &RegistryTarget, + canonical_id: String, + raw: RawRegistryValue, +) -> Observation { + let run_once_prefix = match target.startup_kind { + RegistryStartupKind::Run => None, + RegistryStartupKind::RunOnce => Some(classify_run_once_prefix_units(&raw.name_utf16)), + }; + Observation { + collector_id: REGISTRY_STARTUP_COLLECTOR_ID.to_owned(), + collector_version: REGISTRY_STARTUP_COLLECTOR_VERSION, + scope_id: target.scope_id.to_owned(), + canonical_id, + artifact: Artifact::RegistryStartup(RegistryStartupEntry { + hive: match target.root { + RegistryRoot::CurrentUser => RegistryHive::CurrentUser, + RegistryRoot::LocalMachine => RegistryHive::LocalMachine, + }, + registry_view: target.view, + key_path: target.key_path.to_owned(), + value_name: RegistryValueName::from_utf16_units(&raw.name_utf16), + startup_kind: target.startup_kind, + run_once_prefix, + value_type: raw.value_type, + content_sha256: sha256_hex(&raw.data), + decoding: decode_registry_value(raw.value_type, &raw.data), + raw_evidence: None, + }), + } +} + +pub(crate) fn registry_value_identity(name_utf16: &[u16]) -> String { + let mut hasher = Sha256::new(); + hasher.update(IDENTITY_DOMAIN); + let length = u32::try_from(name_utf16.len()).unwrap_or(u32::MAX); + hasher.update(length.to_le_bytes()); + for unit in name_utf16 { + hasher.update(unit.to_le_bytes()); + } + lower_hex(&hasher.finalize()) +} + +fn sha256_hex(data: &[u8]) -> String { + lower_hex(&Sha256::digest(data)) +} + +fn lower_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + +pub(crate) fn decode_registry_value(value_type: u32, data: &[u8]) -> RegistryValueDecoding { + match value_type { + 1 => decode_single_string(data, false), + 2 => decode_single_string(data, true), + 4 if data.len() == 4 => RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::Dword { + value: u32::from_le_bytes(data.try_into().expect("length was checked")), + }, + }, + 5 if data.len() == 4 => RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::Dword { + value: u32::from_be_bytes(data.try_into().expect("length was checked")), + }, + }, + 7 => decode_multi_string(data), + 11 if data.len() == 8 => RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::Qword { + value: u64::from_le_bytes(data.try_into().expect("length was checked")), + }, + }, + 4 | 5 | 11 => RegistryValueDecoding::InvalidData, + 0 | 3 | 6 | 8 | 9 | 10 => RegistryValueDecoding::NotApplicable, + _ => RegistryValueDecoding::UnsupportedType, + } +} + +fn decode_single_string(data: &[u8], expandable: bool) -> RegistryValueDecoding { + let Some(units) = utf16le_units(data) else { + return RegistryValueDecoding::InvalidData; + }; + let Some((&0, body)) = units.split_last() else { + return RegistryValueDecoding::InvalidData; + }; + if body.contains(&0) { + return RegistryValueDecoding::InvalidData; + } + let Ok(value) = String::from_utf16(body) else { + return RegistryValueDecoding::InvalidData; + }; + RegistryValueDecoding::Decoded { + value: if expandable { + RegistryDecodedValue::ExpandString { value } + } else { + RegistryDecodedValue::String { value } + }, + } +} + +fn decode_multi_string(data: &[u8]) -> RegistryValueDecoding { + let Some(units) = utf16le_units(data) else { + return RegistryValueDecoding::InvalidData; + }; + if units == [0, 0] { + return RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::MultiString { values: Vec::new() }, + }; + } + if units.len() < 2 || !units.ends_with(&[0, 0]) { + return RegistryValueDecoding::InvalidData; + } + + let mut values = Vec::new(); + let mut start = 0; + let list_end = units.len() - 1; + while start < list_end { + let Some(relative_end) = units[start..list_end].iter().position(|unit| *unit == 0) else { + return RegistryValueDecoding::InvalidData; + }; + let end = start + relative_end; + if end == start { + return RegistryValueDecoding::InvalidData; + } + let Ok(value) = String::from_utf16(&units[start..end]) else { + return RegistryValueDecoding::InvalidData; + }; + values.push(value); + start = end + 1; + } + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::MultiString { values }, + } +} + +fn utf16le_units(data: &[u8]) -> Option> { + if !data.len().is_multiple_of(2) { + return None; + } + Some( + data.chunks_exact(2) + .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]])) + .collect(), + ) +} + +fn diagnostic_from_failure(scope_id: &str, failure: &SourceFailure) -> Diagnostic { + let (code, message) = match failure.kind { + SourceFailureKind::AccessDenied => ( + "registry_access_denied", + "The current token cannot read this Registry scope.", + ), + SourceFailureKind::KeyDeleted => ( + "registry_changed_during_scan", + "The Registry key changed during bounded collection retries.", + ), + SourceFailureKind::Other => ( + match failure.stage { + "open" => "registry_open_failed", + "query" => "registry_query_failed", + _ => "registry_enumeration_failed", + }, + "The Registry scope could not be collected.", + ), + }; + Diagnostic { + code: code.to_owned(), + message: message.to_owned(), + stage: Some(failure.stage.to_owned()), + native_code: Some(failure.native_code), + scope_id: Some(scope_id.to_owned()), + } +} + +fn aggregate_status(statuses: impl Iterator) -> CollectorStatus { + let statuses: Vec<_> = statuses.collect(); + if statuses + .iter() + .all(|status| *status == CollectorStatus::Complete) + { + return CollectorStatus::Complete; + } + for terminal in [ + CollectorStatus::PermissionDenied, + CollectorStatus::Unavailable, + CollectorStatus::Unsupported, + CollectorStatus::Failed, + ] { + if !statuses.is_empty() && statuses.iter().all(|status| *status == terminal) { + return terminal; + } + } + CollectorStatus::Partial +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + use systemdiff_core::{PrivilegeState, RunOncePrefixSemantics}; + + fn context() -> CollectionContext { + CollectionContext { + privilege: PrivilegeState::StandardUser, + } + } + + fn raw(name: &[u16], value_type: u32, data: &[u8]) -> RawRegistryValue { + RawRegistryValue { + name_utf16: name.to_vec(), + value_type, + data: data.to_vec(), + } + } + + fn stable(records: Vec) -> ReadKeyResult { + let metadata = KeyMetadata { + value_count: records.len() as u32, + last_write: 1, + }; + ReadKeyResult::Present(KeyReadAttempt { + before: metadata, + after: metadata, + records, + issues: Vec::new(), + stable: true, + }) + } + + struct FakeSource { + layout: Result, + reads: BTreeMap<&'static str, VecDeque>, + requested_budgets: Vec<(&'static str, usize)>, + } + + impl RegistryDataSource for FakeSource { + fn detect_layout(&mut self) -> Result { + self.layout.clone() + } + + fn read_key_once( + &mut self, + target: &RegistryTarget, + available_evidence_bytes: usize, + ) -> ReadKeyResult { + self.requested_budgets + .push((target.scope_id, available_evidence_bytes)); + self.reads + .get_mut(target.scope_id) + .and_then(VecDeque::pop_front) + .unwrap_or(ReadKeyResult::Missing) + } + } + + fn fake(layout: RegistryLayout) -> FakeSource { + FakeSource { + layout: Ok(layout), + reads: BTreeMap::new(), + requested_budgets: Vec::new(), + } + } + + #[test] + fn target_plans_have_explicit_views() { + let (x64, unsupported) = targets_for_layout(RegistryLayout::X64); + assert!(unsupported.is_empty()); + assert_eq!(x64.len(), 6); + assert_eq!(x64[0].view, RegistryView::Shared); + assert_eq!(x64[2].view, RegistryView::Registry32); + assert_eq!(x64[4].view, RegistryView::Registry64); + + let (x86, unsupported) = targets_for_layout(RegistryLayout::X86); + assert!(unsupported.is_empty()); + assert_eq!(x86.len(), 4); + assert_eq!(x86[2].view, RegistryView::Native); + + let (arm, unsupported) = targets_for_layout(RegistryLayout::Arm64); + assert_eq!(arm.len(), 2); + assert_eq!(unsupported.len(), 4); + assert!( + unsupported + .iter() + .all(|scope| scope.coverage.status == CollectorStatus::Unsupported) + ); + } + + #[test] + fn exact_identity_is_stable_and_preserves_prefixes_and_empty_name() { + let foo: Vec<_> = "Foo".encode_utf16().collect(); + let alternate_case: Vec<_> = "foo".encode_utf16().collect(); + let bang: Vec<_> = "!Foo".encode_utf16().collect(); + let star: Vec<_> = "*Foo".encode_utf16().collect(); + + assert_eq!(registry_value_identity(&foo), registry_value_identity(&foo)); + assert_ne!( + registry_value_identity(&foo), + registry_value_identity(&alternate_case), + "Collector v1 deliberately keeps exact casing as a documented limitation" + ); + assert_ne!( + registry_value_identity(&foo), + registry_value_identity(&bang) + ); + assert_ne!( + registry_value_identity(&foo), + registry_value_identity(&star) + ); + assert_eq!( + registry_value_identity(&[]), + "599fd94bf0b72f0bf876ae79e94171025d075b9d199c668ebafc647050701c4b" + ); + assert_eq!( + registry_value_identity(&foo), + "e300b1f49c3d61d973561e229a5b174ff27312a0df7c72801f6db2e8bd256a9e" + ); + for (units, expected) in [ + ( + vec![0x66, 0x6f, 0x6f], + "08462b31c01a23a6feb5049cb0880de67353b551fdb7d0d62324dded602d5e8c", + ), + ( + vec![0x21, 0x46, 0x6f, 0x6f], + "246bb80ad302e1d428b58825421b6ec88d372e0e7d68dcf60185332d7607d833", + ), + ( + vec![0x2a, 0x46, 0x6f, 0x6f], + "23c891afd1729eb817401b675d713aea7b9acfa5e9be7103a3c3e522c7edec94", + ), + ( + vec![0x61, 0x7c, 0x62], + "118571aa221e07649dbb40210959903b5de36297f1ed88d1335651fb005314ff", + ), + ( + vec![0x00c5], + "f02e721daa1064b51f301672cfa9395c953e0a3d9b94b12fddb8c9c79aaf4ae0", + ), + ( + vec![0xd800], + "5ee36fbdb7c75bf2b14efdc3f6ef779b4bcc89dd0ee04d4eda22067e411ed5de", + ), + ] { + assert_eq!(registry_value_identity(&units), expected); + } + } + + #[test] + fn strict_decoding_covers_supported_and_malformed_values() { + let utf16 = |value: &str| { + value + .encode_utf16() + .chain([0]) + .flat_map(u16::to_le_bytes) + .collect::>() + }; + assert!(matches!( + decode_registry_value(1, &utf16("value")), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::String { ref value } + } if value == "value" + )); + assert!(matches!( + decode_registry_value(2, &utf16("%PATH%")), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::ExpandString { ref value } + } if value == "%PATH%" + )); + assert!(matches!( + decode_registry_value(4, &42_u32.to_le_bytes()), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::Dword { value: 42 } + } + )); + assert!(matches!( + decode_registry_value(5, &42_u32.to_be_bytes()), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::Dword { value: 42 } + } + )); + assert!(matches!( + decode_registry_value(11, &42_u64.to_le_bytes()), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::Qword { value: 42 } + } + )); + assert_eq!( + decode_registry_value(3, &[]), + RegistryValueDecoding::NotApplicable + ); + assert_eq!( + decode_registry_value(99, &[]), + RegistryValueDecoding::UnsupportedType + ); + assert_eq!( + decode_registry_value(1, &[]), + RegistryValueDecoding::InvalidData + ); + assert_eq!( + decode_registry_value(1, &[0]), + RegistryValueDecoding::InvalidData + ); + assert_eq!( + decode_registry_value(1, &[0x00, 0xd8, 0, 0]), + RegistryValueDecoding::InvalidData + ); + } + + #[test] + fn multi_string_requires_documented_termination_and_preserves_order() { + let units = [ + b'o' as u16, + b'n' as u16, + b'e' as u16, + 0, + b't' as u16, + b'w' as u16, + b'o' as u16, + 0, + 0, + ]; + let bytes: Vec<_> = units.into_iter().flat_map(u16::to_le_bytes).collect(); + assert!(matches!( + decode_registry_value(7, &bytes), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::MultiString { ref values } + } if values == &["one", "two"] + )); + assert!(matches!( + decode_registry_value(7, &[0, 0, 0, 0]), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::MultiString { ref values } + } if values.is_empty() + )); + assert_eq!( + decode_registry_value(7, &[0, 0]), + RegistryValueDecoding::InvalidData + ); + assert_eq!( + decode_registry_value(7, &[b'o', 0, 0, 0]), + RegistryValueDecoding::InvalidData + ); + } + + #[test] + fn missing_denied_and_mixed_scopes_degrade_independently() { + let mut source = fake(RegistryLayout::X86); + source.reads.insert( + "current_user.shared.run", + VecDeque::from([ReadKeyResult::Failed(SourceFailure { + kind: SourceFailureKind::AccessDenied, + stage: "open", + native_code: 5, + })]), + ); + let outcome = collect_with_source(&mut source, &context()); + assert_eq!(outcome.run.status, CollectorStatus::Partial); + assert_eq!( + outcome.run.coverage[0].status, + CollectorStatus::PermissionDenied + ); + assert!( + outcome + .run + .coverage + .iter() + .skip(1) + .all(|coverage| coverage.status == CollectorStatus::Complete) + ); + } + + #[test] + fn bounded_mutation_retry_uses_only_stable_attempt() { + let mut source = fake(RegistryLayout::X86); + let name: Vec<_> = "Stable".encode_utf16().collect(); + let unstable = ReadKeyResult::Present(KeyReadAttempt { + before: KeyMetadata { + value_count: 0, + last_write: 1, + }, + after: KeyMetadata { + value_count: 1, + last_write: 2, + }, + records: vec![raw(&name, 3, b"old")], + issues: Vec::new(), + stable: false, + }); + source.reads.insert( + "current_user.shared.run", + VecDeque::from([unstable, stable(vec![raw(&name, 3, b"new")])]), + ); + let outcome = collect_with_source(&mut source, &context()); + assert_eq!(outcome.run.status, CollectorStatus::Complete); + assert_eq!(outcome.observations.len(), 1); + let Artifact::RegistryStartup(entry) = &outcome.observations[0].artifact else { + panic!("Registry evidence expected"); + }; + assert_eq!(entry.content_sha256, sha256_hex(b"new")); + } + + #[test] + fn exhausted_mutation_retry_retains_only_final_complete_records_as_partial() { + let mut source = fake(RegistryLayout::X86); + let attempts = [ + b"first".as_slice(), + b"second".as_slice(), + b"final".as_slice(), + ] + .into_iter() + .map(|data| { + ReadKeyResult::Present(KeyReadAttempt { + before: KeyMetadata { + value_count: 0, + last_write: 1, + }, + after: KeyMetadata { + value_count: 1, + last_write: 2, + }, + records: vec![raw(&"Changing".encode_utf16().collect::>(), 3, data)], + issues: Vec::new(), + stable: false, + }) + }) + .collect::>(); + source.reads.insert("current_user.shared.run", attempts); + + let outcome = collect_with_source(&mut source, &context()); + assert_eq!(outcome.run.status, CollectorStatus::Partial); + assert_eq!(outcome.observations.len(), 1); + let Artifact::RegistryStartup(entry) = &outcome.observations[0].artifact else { + panic!("Registry evidence expected"); + }; + assert_eq!(entry.content_sha256, sha256_hex(b"final")); + assert_eq!( + outcome + .run + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == "registry_changed_during_scan") + .count(), + 1 + ); + } + + #[test] + fn normalization_is_independent_of_enumeration_order() { + let alpha = raw(&"Alpha".encode_utf16().collect::>(), 3, b"a"); + let beta = raw(&"Beta".encode_utf16().collect::>(), 3, b"b"); + let mut forward = fake(RegistryLayout::X86); + forward.reads.insert( + "current_user.shared.run", + VecDeque::from([stable(vec![alpha.clone(), beta.clone()])]), + ); + let mut reverse = fake(RegistryLayout::X86); + reverse.reads.insert( + "current_user.shared.run", + VecDeque::from([stable(vec![beta, alpha])]), + ); + + assert_eq!( + collect_with_source(&mut forward, &context()), + collect_with_source(&mut reverse, &context()) + ); + } + + #[test] + fn duplicate_exact_names_are_omitted_as_an_identity_collision() { + let name: Vec<_> = "Duplicate".encode_utf16().collect(); + let mut source = fake(RegistryLayout::X86); + source.reads.insert( + "current_user.shared.run", + VecDeque::from([stable(vec![ + raw(&name, 3, b"first"), + raw(&name, 3, b"second"), + ])]), + ); + + let outcome = collect_with_source(&mut source, &context()); + assert_eq!(outcome.run.status, CollectorStatus::Partial); + assert!(outcome.observations.is_empty()); + assert!(outcome.run.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "registry_identity_collision" + && diagnostic.scope_id.as_deref() == Some("current_user.shared.run") + })); + } + + #[test] + fn partial_scope_retains_complete_sibling_records() { + let mut source = fake(RegistryLayout::X86); + let normal: Vec<_> = "Normal".encode_utf16().collect(); + let metadata = KeyMetadata { + value_count: 2, + last_write: 1, + }; + source.reads.insert( + "current_user.shared.run", + VecDeque::from([ReadKeyResult::Present(KeyReadAttempt { + before: metadata, + after: metadata, + records: vec![raw(&normal, 3, b"complete")], + issues: vec![SourceIssue { + code: "registry_value_too_large", + message: "A Registry value exceeded the SystemDiff per-value capture limit.", + stage: "enumerate", + native_code: None, + }], + stable: true, + })]), + ); + let outcome = collect_with_source(&mut source, &context()); + assert_eq!(outcome.run.status, CollectorStatus::Partial); + assert_eq!(outcome.observations.len(), 1); + assert!(outcome.run.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "registry_value_too_large" + && diagnostic.scope_id.as_deref() == Some("current_user.shared.run") + })); + } + + #[test] + fn evidence_budget_counts_long_names_and_retains_a_fitting_sibling() { + let target = current_user_targets().remove(0); + let records = vec![ + raw(&"LongNameOne".encode_utf16().collect::>(), 3, &[]), + raw(&"LongNameTwo".encode_utf16().collect::>(), 3, &[]), + raw(&"A".encode_utf16().collect::>(), 3, &[]), + ]; + let metadata = KeyMetadata { + value_count: records.len() as u32, + last_write: 1, + }; + let result = build_target_collection( + &target, + KeyReadAttempt { + before: metadata, + after: metadata, + records, + issues: Vec::new(), + stable: true, + }, + size_of::(), + ); + + assert_eq!(result.scope.coverage.status, CollectorStatus::Partial); + assert_eq!(result.retained_evidence_bytes, size_of::()); + assert_eq!(result.observations.len(), 1); + let Artifact::RegistryStartup(entry) = &result.observations[0].artifact else { + panic!("Registry evidence expected"); + }; + assert_eq!(entry.value_name, RegistryValueName::decoded("A")); + assert!( + result + .scope + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "registry_resource_limit") + ); + } + + #[test] + fn evidence_budget_is_decremented_across_registry_scopes() { + let mut source = fake(RegistryLayout::X86); + source.reads.insert( + "current_user.shared.run", + VecDeque::from([stable(vec![raw( + &"A".encode_utf16().collect::>(), + 3, + &[0; 6], + )])]), + ); + source.reads.insert( + "current_user.shared.run_once", + VecDeque::from([stable(vec![raw( + &"B".encode_utf16().collect::>(), + 3, + &[0; 4], + )])]), + ); + + let outcome = collect_with_source_and_budget(&mut source, &context(), 12); + assert_eq!(source.requested_budgets[0].1, 12); + assert_eq!(source.requested_budgets[1].1, 4); + assert_eq!(outcome.observations.len(), 1); + assert_eq!(outcome.run.status, CollectorStatus::Partial); + assert!(outcome.run.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "registry_resource_limit" + && diagnostic.scope_id.as_deref() == Some("current_user.shared.run_once") + })); + } + + #[test] + fn unnamed_run_once_value_is_observed_without_marker_corruption() { + let target = current_user_targets().remove(1); + let observation = + observation_from_raw(&target, registry_value_identity(&[]), raw(&[], 3, &[])); + let Artifact::RegistryStartup(entry) = observation.artifact else { + panic!("Registry evidence expected"); + }; + assert_eq!(entry.value_name, RegistryValueName::decoded("")); + assert_eq!( + entry.run_once_prefix, + Some(RunOncePrefixSemantics::NoDocumentedPrefix) + ); + } +} diff --git a/crates/systemdiff-windows/src/win32.rs b/crates/systemdiff-windows/src/win32.rs new file mode 100644 index 0000000..fe21c81 --- /dev/null +++ b/crates/systemdiff-windows/src/win32.rs @@ -0,0 +1,991 @@ +// These helpers are production code on Windows and remain available on other +// targets so deterministic buffer/resource tests can run without Win32 APIs. +#![cfg_attr(not(windows), allow(dead_code))] + +use crate::registry::{ + MAX_REGISTRY_VALUE_DATA_BYTES, MAX_REGISTRY_VALUES_PER_SCOPE, RawRegistryValue, SourceIssue, + native_evidence_bytes, registry_resource_limit_issue, registry_value_identity, +}; +use std::collections::BTreeMap; +use std::mem::size_of; +use systemdiff_core::REGISTRY_VALUE_NAME_MAX_UTF16_UNITS; + +const MAX_BUFFER_GROWTH_ATTEMPTS: usize = 3; + +#[derive(Default)] +struct BoundedRecords { + by_identity: BTreeMap>, + retained_evidence_bytes: usize, + duplicate_exact_name: bool, +} + +impl BoundedRecords { + fn insert(&mut self, record: RawRegistryValue, evidence_budget: usize) -> bool { + let Some(record_bytes) = native_evidence_bytes(&record) else { + return false; + }; + let Some(total) = self.retained_evidence_bytes.checked_add(record_bytes) else { + return false; + }; + let identity = registry_value_identity(&record.name_utf16); + let group = self.by_identity.entry(identity).or_default(); + self.duplicate_exact_name |= group + .iter() + .any(|existing| existing.name_utf16 == record.name_utf16); + group.push(record); + self.retained_evidence_bytes = total; + + let mut omitted = false; + while self.retained_evidence_bytes > evidence_budget { + let Some(last_identity) = self.by_identity.keys().next_back().cloned() else { + break; + }; + let mut remove_group = false; + if let Some(group) = self.by_identity.get_mut(&last_identity) { + if let Some(removed) = group.pop() { + self.retained_evidence_bytes = self + .retained_evidence_bytes + .saturating_sub(native_evidence_bytes(&removed).unwrap_or(usize::MAX)); + omitted = true; + } + remove_group = group.is_empty(); + } + if remove_group { + self.by_identity.remove(&last_identity); + } + } + !omitted + } + + fn into_records(self) -> Vec { + self.by_identity + .into_values() + .flat_map(|records| records.into_iter()) + .collect() + } + + fn has_duplicate_exact_name(&self) -> bool { + self.duplicate_exact_name + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EnumerationCallStatus { + Success, + MoreData, + NoMoreItems, + KeyDeleted(i64), + Failed(i64), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EnumerationCallResult { + status: EnumerationCallStatus, + name_length: u32, + value_type: u32, + data_length: u32, +} + +enum ProbeResult { + NoMoreItems, + Value { + name: Vec, + value_type: u32, + required_data_bytes: u32, + name_buffer: Vec, + }, + Skipped(SourceIssue), + Unstable(SourceIssue), +} + +fn probe_value_with(mut name_buffer: Vec, mut enumerate: F) -> ProbeResult +where + F: FnMut(&mut [u16]) -> EnumerationCallResult, +{ + for _ in 0..MAX_BUFFER_GROWTH_ATTEMPTS { + let result = enumerate(&mut name_buffer); + match result.status { + EnumerationCallStatus::Success => { + let name_length = match usize::try_from(result.name_length) { + Ok(length) if length <= name_buffer.len() => length, + _ => return ProbeResult::Unstable(invalid_length_issue()), + }; + return ProbeResult::Value { + name: name_buffer[..name_length].to_vec(), + value_type: result.value_type, + required_data_bytes: result.data_length, + name_buffer, + }; + } + EnumerationCallStatus::NoMoreItems => return ProbeResult::NoMoreItems, + EnumerationCallStatus::MoreData => { + let maximum_capacity = REGISTRY_VALUE_NAME_MAX_UTF16_UNITS + 1; + if name_buffer.len() >= maximum_capacity { + return ProbeResult::Skipped(name_too_large_issue()); + } + let grown = name_buffer + .len() + .saturating_mul(2) + .max(1) + .min(maximum_capacity); + name_buffer.resize(grown, 0); + } + EnumerationCallStatus::KeyDeleted(code) => { + return ProbeResult::Unstable(changed_issue(Some(code))); + } + EnumerationCallStatus::Failed(code) => { + return ProbeResult::Unstable(enumeration_issue(code)); + } + } + } + ProbeResult::Skipped(name_too_large_issue()) +} + +enum BufferedValueRead { + Record(RawRegistryValue), + Skipped(SourceIssue), + Unstable(SourceIssue), +} + +fn read_buffered_value_with( + expected_name: Vec, + expected_type: u32, + mut name_buffer: Vec, + initial_data_bytes: usize, + available_evidence_bytes: usize, + mut enumerate: F, +) -> BufferedValueRead +where + F: FnMut(&mut [u16], &mut [u8]) -> EnumerationCallResult, +{ + let mut data = vec![0_u8; initial_data_bytes]; + for _ in 0..MAX_BUFFER_GROWTH_ATTEMPTS { + let result = enumerate(&mut name_buffer, &mut data); + match result.status { + EnumerationCallStatus::Success => { + let name_length = match usize::try_from(result.name_length) { + Ok(length) if length <= name_buffer.len() => length, + _ => return BufferedValueRead::Unstable(invalid_length_issue()), + }; + let data_length = match usize::try_from(result.data_length) { + Ok(length) if length <= data.len() => length, + _ => return BufferedValueRead::Unstable(invalid_length_issue()), + }; + if name_buffer[..name_length] != expected_name || result.value_type != expected_type + { + return BufferedValueRead::Unstable(changed_issue(None)); + } + data.truncate(data_length); + return BufferedValueRead::Record(RawRegistryValue { + name_utf16: expected_name, + value_type: expected_type, + data, + }); + } + EnumerationCallStatus::MoreData => { + let required = match usize::try_from(result.data_length) { + Ok(required) => required, + Err(_) => return BufferedValueRead::Skipped(value_too_large_issue()), + }; + if required > MAX_REGISTRY_VALUE_DATA_BYTES { + return BufferedValueRead::Skipped(value_too_large_issue()); + } + let grown = required.max(data.len().saturating_mul(2)).max(1); + if grown > MAX_REGISTRY_VALUE_DATA_BYTES + || !fits_evidence_budget(&expected_name, grown, available_evidence_bytes) + { + return BufferedValueRead::Skipped(registry_resource_limit_issue()); + } + data.resize(grown, 0); + let maximum_name_capacity = REGISTRY_VALUE_NAME_MAX_UTF16_UNITS + 1; + if name_buffer.len() < maximum_name_capacity { + let grown_name = name_buffer + .len() + .saturating_mul(2) + .max(1) + .min(maximum_name_capacity); + name_buffer.resize(grown_name, 0); + } + } + EnumerationCallStatus::KeyDeleted(code) => { + return BufferedValueRead::Unstable(changed_issue(Some(code))); + } + EnumerationCallStatus::Failed(code) => { + return BufferedValueRead::Unstable(enumeration_issue(code)); + } + EnumerationCallStatus::NoMoreItems => { + return BufferedValueRead::Unstable(enumeration_issue(259)); + } + } + } + BufferedValueRead::Skipped(SourceIssue { + code: "registry_resource_limit", + message: "A Registry value kept changing size during bounded retries.", + stage: "enumerate", + native_code: None, + }) +} + +fn fits_evidence_budget(name: &[u16], data_bytes: usize, available_bytes: usize) -> bool { + name.len() + .checked_mul(size_of::()) + .and_then(|name_bytes| name_bytes.checked_add(data_bytes)) + .is_some_and(|required| required <= available_bytes) +} + +fn scope_value_limit_exhausted(enumerated: u32, observed_count: u32) -> bool { + enumerated == MAX_REGISTRY_VALUES_PER_SCOPE && observed_count > MAX_REGISTRY_VALUES_PER_SCOPE +} + +fn enumeration_count_is_consistent(enumerated: u32, observed_count: u32) -> bool { + enumerated == observed_count || scope_value_limit_exhausted(enumerated, observed_count) +} + +fn value_too_large_issue() -> SourceIssue { + SourceIssue { + code: "registry_value_too_large", + message: "A Registry value exceeded the SystemDiff per-value capture limit.", + stage: "enumerate", + native_code: None, + } +} + +fn name_too_large_issue() -> SourceIssue { + SourceIssue { + code: "registry_resource_limit", + message: "A Registry value name exceeded the bounded Collector buffer.", + stage: "enumerate", + native_code: None, + } +} + +fn changed_issue(native_code: Option) -> SourceIssue { + SourceIssue { + code: "registry_changed_during_scan", + message: "The Registry key changed during bounded collection retries.", + stage: "enumerate", + native_code, + } +} + +fn enumeration_issue(native_code: i64) -> SourceIssue { + SourceIssue { + code: "registry_enumeration_failed", + message: "Registry value enumeration failed.", + stage: "enumerate", + native_code: Some(native_code), + } +} + +fn invalid_length_issue() -> SourceIssue { + SourceIssue { + code: "registry_enumeration_failed", + message: "Registry enumeration returned an invalid buffer length.", + stage: "enumerate", + native_code: None, + } +} + +#[cfg(windows)] +mod platform { + use super::{ + BoundedRecords, BufferedValueRead, EnumerationCallResult, EnumerationCallStatus, + ProbeResult, changed_issue, enumeration_count_is_consistent, fits_evidence_budget, + probe_value_with, read_buffered_value_with, scope_value_limit_exhausted, + value_too_large_issue, + }; + + use crate::registry::{ + KeyMetadata, KeyReadAttempt, MAX_REGISTRY_VALUE_DATA_BYTES, MAX_REGISTRY_VALUES_PER_SCOPE, + RawRegistryValue, ReadKeyResult, RegistryDataSource, RegistryLayout, RegistryRoot, + RegistryTarget, SourceFailure, SourceFailureKind, SourceIssue, ViewSelector, + registry_resource_limit_issue, + }; + use systemdiff_core::REGISTRY_VALUE_NAME_MAX_UTF16_UNITS; + use windows::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_KEY_DELETED, ERROR_MORE_DATA, + ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, FILETIME, GetLastError, WIN32_ERROR, + }; + use windows::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_QUERY_VALUE, KEY_WOW64_32KEY, + KEY_WOW64_64KEY, REG_SAM_FLAGS, RegCloseKey, RegEnumValueW, RegOpenKeyExW, + RegQueryInfoKeyW, + }; + use windows::Win32::System::SystemInformation::{ + IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_I386, + IMAGE_FILE_MACHINE_UNKNOWN, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2}; + use windows::core::{PCWSTR, PWSTR}; + + pub(crate) struct Win32RegistrySource; + + impl Win32RegistrySource { + pub(crate) fn new() -> Self { + Self + } + } + + impl RegistryDataSource for Win32RegistrySource { + fn detect_layout(&mut self) -> Result { + let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; + let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + // SAFETY: GetCurrentProcess returns a process pseudo-handle valid for + // this call, and both output pointers reference initialized local + // IMAGE_FILE_MACHINE values for the duration of the call. + if unsafe { + IsWow64Process2( + GetCurrentProcess(), + &mut process_machine, + Some(&mut native_machine), + ) + } + .is_err() + { + // SAFETY: read immediately after the failed Win32 call above. + let status = unsafe { GetLastError() }; + return Err(SourceFailure { + kind: SourceFailureKind::Other, + stage: "detect_layout", + native_code: i64::from(status.0), + }); + } + + Ok(if native_machine == IMAGE_FILE_MACHINE_AMD64 { + RegistryLayout::X64 + } else if native_machine == IMAGE_FILE_MACHINE_I386 { + RegistryLayout::X86 + } else if native_machine == IMAGE_FILE_MACHINE_ARM64 { + RegistryLayout::Arm64 + } else { + RegistryLayout::Unknown(native_machine.0) + }) + } + + fn read_key_once( + &mut self, + target: &RegistryTarget, + available_evidence_bytes: usize, + ) -> ReadKeyResult { + read_key_once(target, available_evidence_bytes) + } + } + + struct OwnedRegistryKey(HKEY); + + impl OwnedRegistryKey { + fn open(target: &RegistryTarget) -> Result, SourceFailure> { + let root = match target.root { + RegistryRoot::CurrentUser => HKEY_CURRENT_USER, + RegistryRoot::LocalMachine => HKEY_LOCAL_MACHINE, + }; + let selector = match target.selector { + ViewSelector::Shared | ViewSelector::Native => REG_SAM_FLAGS(0), + ViewSelector::Registry32 => KEY_WOW64_32KEY, + ViewSelector::Registry64 => KEY_WOW64_64KEY, + }; + let path: Vec = target.key_path.encode_utf16().chain([0]).collect(); + let mut opened = HKEY::default(); + // SAFETY: root is a borrowed predefined handle, path is NUL-terminated + // for the call, desired access is query-only, and opened is a valid + // out pointer. Ownership is assumed only after ERROR_SUCCESS. + let status = unsafe { + RegOpenKeyExW( + root, + PCWSTR(path.as_ptr()), + None, + KEY_QUERY_VALUE | selector, + &mut opened, + ) + }; + match status { + ERROR_SUCCESS => Ok(Some(Self(opened))), + ERROR_FILE_NOT_FOUND => Ok(None), + ERROR_ACCESS_DENIED => Err(source_failure("open", status)), + _ => Err(source_failure("open", status)), + } + } + } + + impl Drop for OwnedRegistryKey { + fn drop(&mut self) { + // SAFETY: this wrapper owns exactly one handle returned by a + // successful RegOpenKeyExW. Predefined root handles are never wrapped. + let _ = unsafe { RegCloseKey(self.0) }; + } + } + + fn read_key_once(target: &RegistryTarget, available_evidence_bytes: usize) -> ReadKeyResult { + let key = match OwnedRegistryKey::open(target) { + Ok(Some(key)) => key, + Ok(None) => return ReadKeyResult::Missing, + Err(failure) => return ReadKeyResult::Failed(failure), + }; + let before = match query_metadata(key.0) { + Ok(metadata) => metadata, + Err(failure) => return ReadKeyResult::Failed(failure), + }; + + let mut records = BoundedRecords::default(); + let mut issues = Vec::new(); + let initial_name_capacity = usize::try_from(before.max_value_name_units) + .ok() + .and_then(|length| length.checked_add(1)) + .unwrap_or(1) + .clamp(1, REGISTRY_VALUE_NAME_MAX_UTF16_UNITS + 1); + let mut index = 0_u32; + + while index < MAX_REGISTRY_VALUES_PER_SCOPE { + match read_value( + key.0, + index, + initial_name_capacity, + available_evidence_bytes, + ) { + ValueRead::NoMoreItems => break, + ValueRead::Record(record) => { + if !records.insert(record, available_evidence_bytes) { + issues.push(registry_resource_limit_issue()); + } + index += 1; + } + ValueRead::Skipped(issue) => { + issues.push(issue); + index += 1; + } + ValueRead::Unstable(issue) => { + issues.push(issue); + return ReadKeyResult::Present(KeyReadAttempt { + before: before.metadata, + after: before.metadata, + records: records.into_records(), + issues, + stable: false, + }); + } + } + } + + let after = match query_metadata(key.0) { + Ok(metadata) => metadata, + Err(failure) if failure.kind == SourceFailureKind::KeyDeleted => { + issues.push(issue_from_failure(&failure)); + return ReadKeyResult::Present(KeyReadAttempt { + before: before.metadata, + after: before.metadata, + records: records.into_records(), + issues, + stable: false, + }); + } + Err(failure) => { + issues.push(issue_from_failure(&failure)); + return ReadKeyResult::Present(KeyReadAttempt { + before: before.metadata, + after: before.metadata, + records: records.into_records(), + issues, + stable: false, + }); + } + }; + let value_limit_exhausted = scope_value_limit_exhausted(index, after.metadata.value_count); + if value_limit_exhausted { + issues.push(SourceIssue { + code: "registry_resource_limit", + message: "The Registry scope exceeded the SystemDiff value-count capture limit.", + stage: "enumerate", + native_code: None, + }); + } + let count_is_consistent = + enumeration_count_is_consistent(index, after.metadata.value_count); + if !count_is_consistent { + issues.push(changed_issue(None)); + } + if records.has_duplicate_exact_name() { + issues.push(changed_issue(None)); + } + let stable = before.metadata == after.metadata + && count_is_consistent + && !records.has_duplicate_exact_name(); + ReadKeyResult::Present(KeyReadAttempt { + before: before.metadata, + after: after.metadata, + records: records.into_records(), + issues, + stable, + }) + } + + #[derive(Clone, Copy)] + struct QueryMetadata { + metadata: KeyMetadata, + max_value_name_units: u32, + } + + fn query_metadata(key: HKEY) -> Result { + let mut value_count = 0_u32; + let mut max_value_name_units = 0_u32; + let mut max_value_data_bytes = 0_u32; + let mut last_write = FILETIME::default(); + // SAFETY: key is open for KEY_QUERY_VALUE. Every supplied output pointer + // references initialized storage of the documented unit; unused outputs + // are null through None. + let status = unsafe { + RegQueryInfoKeyW( + key, + None, + None, + None, + None, + None, + None, + Some(&mut value_count), + Some(&mut max_value_name_units), + Some(&mut max_value_data_bytes), + None, + Some(&mut last_write), + ) + }; + if status != ERROR_SUCCESS { + return Err(source_failure("query", status)); + } + let _ = max_value_data_bytes; + Ok(QueryMetadata { + metadata: KeyMetadata { + value_count, + last_write: (u64::from(last_write.dwHighDateTime) << 32) + | u64::from(last_write.dwLowDateTime), + }, + max_value_name_units, + }) + } + + enum ValueRead { + NoMoreItems, + Record(RawRegistryValue), + Skipped(SourceIssue), + Unstable(SourceIssue), + } + + fn read_value( + key: HKEY, + index: u32, + initial_name_capacity: usize, + available_evidence_bytes: usize, + ) -> ValueRead { + let (name, value_type, required_data_bytes, name_buffer) = + match probe_value(key, index, vec![0_u16; initial_name_capacity]) { + ProbeResult::NoMoreItems => return ValueRead::NoMoreItems, + ProbeResult::Value { + name, + value_type, + required_data_bytes, + name_buffer, + } => (name, value_type, required_data_bytes, name_buffer), + ProbeResult::Skipped(issue) => return ValueRead::Skipped(issue), + ProbeResult::Unstable(issue) => return ValueRead::Unstable(issue), + }; + let required = match usize::try_from(required_data_bytes) { + Ok(required) => required, + Err(_) => return ValueRead::Skipped(value_too_large_issue()), + }; + if required > MAX_REGISTRY_VALUE_DATA_BYTES { + return ValueRead::Skipped(value_too_large_issue()); + } + if !fits_evidence_budget(&name, required, available_evidence_bytes) { + return ValueRead::Skipped(registry_resource_limit_issue()); + } + if required == 0 { + return ValueRead::Record(RawRegistryValue { + name_utf16: name, + value_type, + data: Vec::new(), + }); + } + + match read_buffered_value_with( + name, + value_type, + name_buffer, + required, + available_evidence_bytes, + |name_buffer, data_buffer| { + let mut name_length = u32::try_from(name_buffer.len()) + .expect("bounded Registry name buffer must fit u32"); + let mut data_length = u32::try_from(data_buffer.len()) + .expect("bounded Registry data buffer must fit u32"); + let mut read_type = 0_u32; + // SAFETY: both buffers are initialized and their capacities are + // supplied in the units required by RegEnumValueW. The pure + // state machine validates all returned lengths before slicing. + let status = unsafe { + RegEnumValueW( + key, + index, + Some(PWSTR(name_buffer.as_mut_ptr())), + &mut name_length, + None, + Some(&mut read_type), + Some(data_buffer.as_mut_ptr()), + Some(&mut data_length), + ) + }; + EnumerationCallResult { + status: if status == ERROR_SUCCESS { + EnumerationCallStatus::Success + } else if status == ERROR_MORE_DATA { + EnumerationCallStatus::MoreData + } else if status == ERROR_KEY_DELETED { + EnumerationCallStatus::KeyDeleted(i64::from(status.0)) + } else if status == ERROR_NO_MORE_ITEMS { + EnumerationCallStatus::NoMoreItems + } else { + EnumerationCallStatus::Failed(i64::from(status.0)) + }, + name_length, + value_type: read_type, + data_length, + } + }, + ) { + BufferedValueRead::Record(record) => ValueRead::Record(record), + BufferedValueRead::Skipped(issue) => ValueRead::Skipped(issue), + BufferedValueRead::Unstable(issue) => ValueRead::Unstable(issue), + } + } + + fn probe_value(key: HKEY, index: u32, name_buffer: Vec) -> ProbeResult { + probe_value_with(name_buffer, |name_buffer| { + let mut name_length = u32::try_from(name_buffer.len()) + .expect("bounded Registry name buffer must fit u32"); + let mut read_type = 0_u32; + let mut required_data_bytes = 0_u32; + // SAFETY: the initialized name buffer and its UTF-16 capacity are + // valid for the call. lpData is null intentionally; lpcbData receives + // the required native byte count without copying value data. The + // pure state machine validates the returned name length. + let status = unsafe { + RegEnumValueW( + key, + index, + Some(PWSTR(name_buffer.as_mut_ptr())), + &mut name_length, + None, + Some(&mut read_type), + None, + Some(&mut required_data_bytes), + ) + }; + EnumerationCallResult { + status: if status == ERROR_SUCCESS { + EnumerationCallStatus::Success + } else if status == ERROR_NO_MORE_ITEMS { + EnumerationCallStatus::NoMoreItems + } else if status == ERROR_MORE_DATA { + EnumerationCallStatus::MoreData + } else if status == ERROR_KEY_DELETED { + EnumerationCallStatus::KeyDeleted(i64::from(status.0)) + } else { + EnumerationCallStatus::Failed(i64::from(status.0)) + }, + name_length, + value_type: read_type, + data_length: required_data_bytes, + } + }) + } + + fn source_failure(stage: &'static str, status: WIN32_ERROR) -> SourceFailure { + SourceFailure { + kind: if status == ERROR_ACCESS_DENIED { + SourceFailureKind::AccessDenied + } else if status == ERROR_KEY_DELETED { + SourceFailureKind::KeyDeleted + } else { + SourceFailureKind::Other + }, + stage, + native_code: i64::from(status.0), + } + } + + fn issue_from_failure(failure: &SourceFailure) -> SourceIssue { + if failure.kind == SourceFailureKind::KeyDeleted { + changed_issue(Some(failure.native_code)) + } else { + SourceIssue { + code: "registry_query_failed", + message: "Registry metadata could not be read after enumeration.", + stage: failure.stage, + native_code: Some(failure.native_code), + } + } + } +} + +#[cfg(not(windows))] +mod platform { + use crate::registry::{ + ReadKeyResult, RegistryDataSource, RegistryLayout, RegistryTarget, SourceFailure, + SourceFailureKind, + }; + + pub(crate) struct Win32RegistrySource; + + impl Win32RegistrySource { + pub(crate) fn new() -> Self { + Self + } + } + + impl RegistryDataSource for Win32RegistrySource { + fn detect_layout(&mut self) -> Result { + Err(SourceFailure { + kind: SourceFailureKind::Other, + stage: "detect_layout", + native_code: 0, + }) + } + + fn read_key_once( + &mut self, + _target: &RegistryTarget, + _available_evidence_bytes: usize, + ) -> ReadKeyResult { + ReadKeyResult::Failed(SourceFailure { + kind: SourceFailureKind::Other, + stage: "open", + native_code: 0, + }) + } + } +} + +pub(crate) use platform::Win32RegistrySource; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probe_retries_name_more_data_and_preserves_byte_length_metadata() { + let mut calls = 0; + let result = probe_value_with(vec![0_u16; 1], |name_buffer| { + calls += 1; + if calls == 1 { + return EnumerationCallResult { + status: EnumerationCallStatus::MoreData, + name_length: 0, + value_type: 0, + data_length: 0, + }; + } + name_buffer[..2].copy_from_slice(&[u16::from(b'A'), u16::from(b'B')]); + EnumerationCallResult { + status: EnumerationCallStatus::Success, + name_length: 2, + value_type: 1, + data_length: 257, + } + }); + + let ProbeResult::Value { + name, + value_type, + required_data_bytes, + name_buffer, + } = result + else { + panic!("probe must succeed after bounded growth"); + }; + assert_eq!(calls, 2); + assert_eq!(name, [u16::from(b'A'), u16::from(b'B')]); + assert_eq!(value_type, 1); + assert_eq!(required_data_bytes, 257); + assert_eq!(name_buffer.len(), 2); + } + + #[test] + fn buffered_read_retries_same_value_after_data_more_data() { + let expected_name: Vec<_> = "Name".encode_utf16().collect(); + let mut calls = 0; + let result = read_buffered_value_with( + expected_name.clone(), + 3, + vec![0_u16; expected_name.len()], + 2, + 64, + |name_buffer, data_buffer| { + calls += 1; + if calls == 1 { + return EnumerationCallResult { + status: EnumerationCallStatus::MoreData, + name_length: expected_name.len() as u32, + value_type: 3, + data_length: 6, + }; + } + name_buffer[..expected_name.len()].copy_from_slice(&expected_name); + data_buffer[..6].copy_from_slice(b"abcdef"); + EnumerationCallResult { + status: EnumerationCallStatus::Success, + name_length: expected_name.len() as u32, + value_type: 3, + data_length: 6, + } + }, + ); + + let BufferedValueRead::Record(record) = result else { + panic!("buffered read must succeed after growth"); + }; + assert_eq!(calls, 2); + assert_eq!(record.name_utf16, expected_name); + assert_eq!(record.data, b"abcdef"); + } + + #[test] + fn buffered_read_stops_after_three_growth_attempts() { + let mut calls = 0; + let result = read_buffered_value_with( + "A".encode_utf16().collect(), + 3, + vec![0_u16; 1], + 1, + 128, + |_name_buffer, data_buffer| { + calls += 1; + EnumerationCallResult { + status: EnumerationCallStatus::MoreData, + name_length: 1, + value_type: 3, + data_length: (data_buffer.len() + 1) as u32, + } + }, + ); + + assert_eq!(calls, MAX_BUFFER_GROWTH_ATTEMPTS); + assert!(matches!( + result, + BufferedValueRead::Skipped(SourceIssue { + code: "registry_resource_limit", + .. + }) + )); + } + + #[test] + fn buffer_state_machine_rejects_invalid_returned_lengths_and_hard_caps() { + let invalid = read_buffered_value_with( + "A".encode_utf16().collect(), + 3, + vec![0_u16; 1], + 1, + usize::MAX, + |_name_buffer, _data_buffer| EnumerationCallResult { + status: EnumerationCallStatus::Success, + name_length: 2, + value_type: 3, + data_length: 1, + }, + ); + assert!(matches!( + invalid, + BufferedValueRead::Unstable(SourceIssue { + code: "registry_enumeration_failed", + .. + }) + )); + + let oversized = read_buffered_value_with( + "A".encode_utf16().collect(), + 3, + vec![0_u16; 1], + 1, + usize::MAX, + |_name_buffer, _data_buffer| EnumerationCallResult { + status: EnumerationCallStatus::MoreData, + name_length: 1, + value_type: 3, + data_length: (MAX_REGISTRY_VALUE_DATA_BYTES + 1) as u32, + }, + ); + assert!(matches!( + oversized, + BufferedValueRead::Skipped(SourceIssue { + code: "registry_value_too_large", + .. + }) + )); + } + + #[test] + fn name_and_value_evidence_and_scope_count_limits_are_exact() { + let name = [u16::from(b'A'); 4]; + assert!(fits_evidence_budget(&name, 0, 8)); + assert!(!fits_evidence_budget(&name, 0, 7)); + assert!(fits_evidence_budget(&name, 4, 12)); + assert!(!fits_evidence_budget(&name, 4, 11)); + + assert!(!scope_value_limit_exhausted( + MAX_REGISTRY_VALUES_PER_SCOPE, + MAX_REGISTRY_VALUES_PER_SCOPE + )); + assert!(scope_value_limit_exhausted( + MAX_REGISTRY_VALUES_PER_SCOPE, + MAX_REGISTRY_VALUES_PER_SCOPE + 1 + )); + + assert!(enumeration_count_is_consistent(0, 0)); + assert!(enumeration_count_is_consistent(7, 7)); + assert!(!enumeration_count_is_consistent(6, 7)); + assert!(!enumeration_count_is_consistent(8, 7)); + assert!(enumeration_count_is_consistent( + MAX_REGISTRY_VALUES_PER_SCOPE, + MAX_REGISTRY_VALUES_PER_SCOPE + 1 + )); + } + + #[test] + fn bounded_record_selection_is_independent_of_enumeration_order() { + fn record(name: &str) -> RawRegistryValue { + RawRegistryValue { + name_utf16: name.encode_utf16().collect(), + value_type: 3, + data: vec![0; 4], + } + } + + fn retained_names(input: [&str; 3]) -> Vec> { + let mut records = BoundedRecords::default(); + for name in input { + let _ = records.insert(record(name), 12); + } + records + .into_records() + .into_iter() + .map(|record| record.name_utf16) + .collect() + } + + assert_eq!( + retained_names(["A", "B", "C"]), + retained_names(["C", "A", "B"]) + ); + } + + #[test] + fn repeated_exact_name_is_a_concurrent_enumeration_anomaly() { + let record = RawRegistryValue { + name_utf16: "Repeated".encode_utf16().collect(), + value_type: 3, + data: vec![0; 4], + }; + let mut records = BoundedRecords::default(); + assert!(records.insert(record.clone(), 1024)); + assert!(records.insert(record, 1024)); + assert!(records.has_duplicate_exact_name()); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index c8cf8fd..9091c73 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,7 +50,7 @@ A Snapshot is an observed state at a point in time. It records document/schema v Snapshots do not claim to be atomic. Coverage and concurrent-change diagnostics are evidence needed to interpret a later diff. -Snapshot files are untrusted input. The CLI enforces a fixed 64 MiB file ceiling with metadata preflight and a bounded read before handing bytes to core. Core then inspects `document_type` and `schema_version` before constructing the supported v1 wire type. This is an intentionally small synchronous boundary, not a streaming parser or general resource-policy framework. +Snapshot files are untrusted input. The CLI enforces a fixed 64 MiB file ceiling with metadata preflight and a bounded read before handing bytes to core. Core then inspects `document_type` and `schema_version` before constructing the supported v1 wire type. Generated Snapshots pass through a capped serializer before create-new file I/O, so oversized output never creates a destination and existing files are never overwritten. This is an intentionally small synchronous boundary, not a streaming parser or general resource-policy framework. ### Collector @@ -81,7 +81,7 @@ Each observation separates: - stable fields that participate in default comparison; - volatile fields that are retained only when useful and excluded from default comparison. -Identity is not a `Debug` string, full-JSON hash, localized display name, or executable judgment. Original Windows casing/value text is preserved even when comparison uses documented case-insensitive semantics. +Identity is not a `Debug` string, full-JSON hash, localized display name, or executable judgment. Original Windows casing/value text is preserved. Registry startup Collector v1 deliberately uses exact UTF-16 value-name evidence rather than claiming an undocumented durable case-folded token; its known casing-only false-split limitation is versioned and documented in [collectors.md](collectors.md). Registry observations keep the native type code separate from a tagged typed decoding result. String, expandable-string, multi-string, DWORD, and QWORD interpretations therefore do not force unknown, binary, or malformed Registry data through a text-only model. A full-content SHA-256 keeps undecoded or truncated values comparable. Optional raw prefixes are limited to 4 KiB and use validated lowercase hex with captured/original sizes and truncation metadata; Collectors do not duplicate raw bytes when decoded evidence is sufficient. @@ -105,11 +105,11 @@ Reports initially support deterministic JSON and a human-readable terminal view. ## Windows collection strategy -The first collectors use Unicode platform APIs through narrowly feature-gated `windows-rs` bindings: +Windows collection uses Unicode platform APIs through narrowly feature-gated `windows-rs` bindings: -- Run/RunOnce: Registry APIs with explicit WOW64 views where applicable. -- Services: Service Control Manager enumeration and configuration query APIs. -- Scheduled Tasks: Task Scheduler 2.0 COM interfaces with recursive folder traversal. +- Run/RunOnce (implemented): Registry APIs with explicit WOW64 views where applicable. +- Services (planned): Service Control Manager enumeration and configuration query APIs. +- Scheduled Tasks (planned): Task Scheduler 2.0 COM interfaces with recursive folder traversal. Command output from `reg.exe`, `sc.exe`, `schtasks.exe`, PowerShell, or WMI is not a data contract and will not be parsed. Detailed coverage and API references live in [collectors.md](collectors.md). @@ -139,8 +139,10 @@ Important choices were checked on 2026-08-11. Re-evaluate them when introduced o | --- | --- | --- | | Rust/Cargo | Stable toolchain; Rust 2024 workspace resolver | Accepted for shared core, memory safety, native distribution, and strong test tooling | | `serde` / `serde_json` | Active; Apache-2.0 OR MIT; [official repository](https://github.com/serde-rs/serde) | Accepted for explicit versioned JSON wire types | -| `time` | Active; Apache-2.0 OR MIT; [official repository](https://github.com/time-rs/time) | Accepted in `systemdiff-core` with only the parsing feature for standards-based RFC 3339 validation; clock, local-offset, formatting, and serde features remain disabled | -| `windows-rs` | Microsoft-maintained and active; Apache-2.0 OR MIT; [official repository](https://github.com/microsoft/windows-rs) | Planned for collectors; enable only required API features and prefer typed bindings | +| `time` | Active; Apache-2.0 OR MIT; [official repository](https://github.com/time-rs/time) | Accepted for standards-based RFC 3339 parsing in core and canonical UTC timestamp formatting in CLI; local-offset and serde features remain disabled | +| `sha2` | Active; Apache-2.0 OR MIT; [RustCrypto repository](https://github.com/RustCrypto/hashes) | Accepted without default features for full native Registry value hashes and versioned evidence identities | +| `windows-rs` | Microsoft-maintained and active; Apache-2.0 OR MIT; [official repository](https://github.com/microsoft/windows-rs) | Accepted in `systemdiff-windows`; only required Win32 feature families are enabled and unsafe calls remain behind narrow adapters | +| `windows-version` | Active; Apache-2.0 OR MIT; [official repository](https://github.com/microsoft/windows-rs) | Accepted for the documented Windows 10/Server version 1709 minimum check without shelling out or parsing localized output | | `clap` | Active; Apache-2.0 OR MIT; [official repository](https://github.com/clap-rs/clap) | Accepted at the CLI boundary only | | Tauri 2 | Active; Apache-2.0 OR MIT; requires C++ Build Tools and WebView2 on Windows; [prerequisites](https://v2.tauri.app/start/prerequisites/) | Proposed for v0.2; excluded from v0.1 build until a security-focused spike | | React / TypeScript / Vite | Active; permissive licenses; frontend support windows require regular upgrades | Proposed with Tauri; lockfile and dependency audit required when introduced | @@ -155,7 +157,7 @@ Do not introduce Tokio/`async-trait`, a database, network client, telemetry SDK, - Coverage transitions such as complete → partial protect against false removals. - Windows adapter tests isolate buffer parsing, normalization, and API error mapping. - Default CI never writes real Run keys, services, or tasks and never requires elevation. -- Privileged Windows integration tests, if added, are explicit opt-in and use disposable, narrowly named resources. +- The write-capable HKCU Registry E2E is a separate test-only PowerShell harness with two explicit gates, exact-data guarded cleanup, no administrator requirement, and no link into production CLI/API code. ## Desktop decision diff --git a/docs/collectors.md b/docs/collectors.md index 06fbba0..4a664e6 100644 --- a/docs/collectors.md +++ b/docs/collectors.md @@ -10,22 +10,26 @@ No MVP Collector writes to the system or executes observed evidence. ## Registry startup entries -Planned ID: `windows.registry.startup` +Implemented ID/version: `windows.registry.startup` v1 -v0.1 covers only the four documented Run/RunOnce logical locations under HKLM and the current HKCU. It is not a complete persistence scan. +The pre-v0.1 implementation covers only the four documented Run/RunOnce logical locations under HKLM and the current HKCU. It is not a complete persistence scan. The current minimum platform is Windows 10 version 1709 or Windows Server 2016 version 1709, matching the documented minimum for `IsWow64Process2`. Implementation source: - `RegOpenKeyExW`, `RegQueryInfoKeyW`, and `RegEnumValueW`. - On 64-bit Windows, enumerate HKLM `Software` in explicit 64-bit and 32-bit views; do not address `Wow6432Node` directly. - On Windows 7 and later, HKCU `Software` is shared and is collected once. +- On ARM64, v1 still collects both HKCU Shared scopes but reports all HKLM alternate-view scopes as `unsupported`. This is a conservative Collector limitation, not a claim that Windows on ARM lacks alternate Registry views. - Emit `Registry32` only after explicitly selecting `KEY_WOW64_32KEY`, and emit `Registry64` only after explicitly selecting `KEY_WOW64_64KEY`. These labels describe logical Registry views, not processor-specific physical stores. - Emit `Shared` only for keys Microsoft documents as shared. Emit `Native` only when the target key has one system view and no WOW64 alternate logical views. A Collector must never use `Native` as a shortcut for omitting a selector on a redirected key, because the resulting default depends on process bitness. -- Preserve the numeric Registry type, a typed safe-decode outcome, original unexpanded `REG_EXPAND_SZ`, and original names/casing. Do not assume every value is UTF-16LE text. +- Preserve the numeric Registry type, a typed safe-decode outcome, original unexpanded `REG_EXPAND_SZ`, and exact UTF-16 value-name code units. Valid Unicode names use a decoded string; invalid UTF-16 names use lossless lowercase UTF-16LE hex rather than replacement characters. Do not assume every value is UTF-16LE text. - Compute SHA-256 over the complete native value bytes before decoding or truncation so undecoded values still compare reliably. -- Retain a raw prefix only when it adds concrete forensic value, encode it as lowercase hex, enforce the 4 KiB schema limit, and record truncation; ordinary decoded Run values do not need duplicated raw payloads by default. +- Registry startup Collector v1 does not retain a value-data raw prefix. Typed evidence, decode status, numeric type, and a complete native-byte SHA-256 avoid duplicating potentially sensitive bytes. The schema's bounded optional raw-evidence field remains available only for a later, separately reviewed privacy need. - Treat value order as unstable and bound any retry when a key changes during enumeration. - A missing Run key is a complete empty scope, not an error. +- Every scope is independent. Access denial, concurrent mutation, invalid data, or a SystemDiff capture limit produces a scoped status/diagnostic without discarding complete sibling evidence or aborting unrelated scopes. + +SystemDiff capture limits are 8 MiB of native data per Registry value, 32 MiB of retained native value-name and value-data evidence for this Collector, and 4,096 enumerated values per scope. These are product resource budgets, not Windows Registry platform limits. An omitted over-limit item makes its scope `partial`; it is never represented by a truncated hash or incomplete observation, and normal sibling observations remain available. RunOnce prefix evidence follows Microsoft's documented value-name behavior: @@ -33,9 +37,11 @@ RunOnce prefix evidence follows Microsoft's documented value-name behavior: - Run and RunOnce keys are ignored in Safe Mode by default. A leading `*` makes a RunOnce value run in Safe Mode. - Microsoft does not define combined, repeated, or marker-only prefix forms. Preserve those complete names as raw evidence and mark their structured interpretation `undocumented`; do not infer that both documented behaviors apply. -The complete value name, including `!` or `*`, remains evidence and part of canonical identity. `Foo`, `!Foo`, and `*Foo` cannot collapse to the same observation. Environment expansion, command-line parsing, executable discovery, hashing, and signature checks are later enrichment stages. +The complete value name, including `!` or `*`, remains evidence and part of canonical identity. `Foo`, `!Foo`, and `*Foo` cannot collapse to the same observation. An empty/default value name is ordinary observable evidence with a stable identity and no inferred prefix semantics. + +Windows Registry value lookup is case-insensitive. Collector v1 nevertheless hashes the exact authoritative UTF-16 code units with a versioned, domain-separated SHA-256 because Microsoft does not expose a documented, durable canonical form suitable for cross-platform Snapshot diffing. This conservative pre-v0.1 limitation avoids silently merging distinct raw evidence but could show a visible Removed + Added pair if Windows ever returns different display casing for the same logical value across captures. A future correction requires a new Collector version and explicit compatibility tests; v1 does not use ad hoc Unicode folding. Environment expansion, command-line parsing, executable discovery, file hashing, and signature checks are later enrichment stages. -Official references: [Run and RunOnce](https://learn.microsoft.com/windows/win32/setupapi/run-and-runonce-registry-keys), [WOW64 affected keys](https://learn.microsoft.com/windows/win32/winprog64/shared-registry-keys), [alternate registry views](https://learn.microsoft.com/windows/win32/winprog64/accessing-an-alternate-registry-view), [RegEnumValueW](https://learn.microsoft.com/windows/win32/api/winreg/nf-winreg-regenumvaluew). +Official references: [Run and RunOnce](https://learn.microsoft.com/windows/win32/setupapi/run-and-runonce-registry-keys), [WOW64 affected keys](https://learn.microsoft.com/windows/win32/winprog64/shared-registry-keys), [alternate registry views](https://learn.microsoft.com/windows/win32/winprog64/accessing-an-alternate-registry-view), [RegEnumValueW](https://learn.microsoft.com/windows/win32/api/winreg/nf-winreg-regenumvaluew), and the documented [case-insensitive Registry value-name lookup](https://learn.microsoft.com/dotnet/api/microsoft.win32.registrykey.getvalue). ## Windows services @@ -82,4 +88,5 @@ Do not recursively hash an entire system drive. Any future Collector starts with - Pure normalization/identity tests run without Windows. - Synthetic fixtures cover unordered input, invalid encodings, access denial, pagination, concurrent deletion/change, and incomplete coverage. - Default Windows smoke tests are read-only and non-elevated. -- Privileged tests that create temporary keys/services/tasks are opt-in, use disposable names, and never touch real Run/RunOnce locations. +- Default CI never writes Registry, service, or task state. +- The only write-capable Registry E2E is the test-only `scripts/run-registry-startup-e2e.ps1` harness. It requires an environment-variable gate and an explicit switch, writes one clearly synthetic HKCU Run value, refuses any existing matching value, verifies exact type/data before guarded cleanup, needs no administrator access, and is not linked into the production CLI or Collector API. diff --git a/docs/data-format.md b/docs/data-format.md index a8469c3..38c749f 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -21,7 +21,7 @@ A diff uses `systemdiff.diff` and its own schema version. Application versions a Readers inspect `document_type` and `schema_version` before deserializing the remaining body. Unknown major versions are rejected with a clear error. Additive top-level metadata may be tolerated, but an unknown typed artifact is never silently discarded. -The CLI accepts Snapshot files up to 64 MiB. It checks metadata before allocating the full input, performs a bounded read of at most the supported maximum plus one byte, and rechecks the actual byte count before JSON decoding. This fixed ceiling provides headroom for targeted v0.1 evidence while bounding parser amplification; it is not a streaming parser or a configurable resource-policy framework. +The CLI accepts and generates Snapshot files up to 64 MiB. Input handling checks metadata before allocating the full input, performs a bounded read of at most the supported maximum plus one byte, and rechecks the actual byte count before JSON decoding. Output is fully serialized through a capped writer before a destination is created. The destination uses create-new semantics and is never overwritten; a write/flush failure reports that the newly created path may be incomplete rather than deleting by pathname. This fixed ceiling provides headroom for targeted v0.1 evidence while bounding parser amplification; it is not a streaming parser or a configurable resource-policy framework. After the bounded read, the core first deserializes a minimal header containing only `document_type` and `schema_version`. Only `systemdiff.snapshot` schema v1 is then routed to the current `Snapshot` wire type. This header pass still scans the bounded JSON to skip unrelated fields; it does not construct a generic JSON DOM or the full Snapshot body before routing. @@ -40,11 +40,11 @@ A snapshot records: Diagnostics include a stable code, collection stage, and optional Win32/HRESULT numeric value. Localized error messages are for humans only. -Readers accept known UTC expressed with `Z` or `+00:00` and reject non-zero offsets. RFC 3339 `-00:00` means that the local offset is unknown, so it is not accepted as a known UTC assertion. Snapshot readers preserve the original valid wire string; future SystemDiff-generated Snapshots will emit canonical `Z`. +Readers accept known UTC expressed with `Z` or `+00:00` and reject non-zero offsets. RFC 3339 `-00:00` means that the local offset is unknown, so it is not accepted as a known UTC assertion. Snapshot readers preserve the original valid wire string; SystemDiff-generated Snapshots emit canonical `Z`. ## Registry value evidence -Registry artifacts preserve the value name and the numeric Windows Registry type code independently from interpretation. The draft typed decoding supports strings, unexpanded expandable strings, multi-strings, DWORDs, and QWORDs; binary, unknown, or malformed values can remain undecoded with an explicit `not_applicable`, `unsupported_type`, or `invalid_data` status. The type code remains authoritative, and decoded kinds are validated against it, so a future Collector never needs to coerce every value into UTF-16LE text. +Registry artifacts preserve the value name and the numeric Windows Registry type code independently from interpretation. `value_name` is a tagged lossless value: valid Unicode is serialized as `{"encoding":"decoded","value":"..."}`, while invalid UTF-16 is preserved as exact lowercase UTF-16LE hex. Empty/default value names are valid evidence and round-trip without special casing. The draft typed value-data decoding supports strings, unexpanded expandable strings, multi-strings, DWORDs, and QWORDs; binary, unknown, or malformed values can remain undecoded with an explicit `not_applicable`, `unsupported_type`, or `invalid_data` status. The type code remains authoritative, and decoded kinds are validated against it, so the Collector never needs to coerce every value into UTF-16LE text. Each startup artifact explicitly identifies whether it came from a `run` or `run_once` key. A Run entry has no RunOnce prefix semantics. A RunOnce entry records exactly one structured interpretation derived from the complete value name: @@ -55,7 +55,9 @@ Each startup artifact explicitly identifies whether it came from a `run` or `run The structured interpretation never replaces or strips the complete `value_name`. The full name, including any prefix, remains raw evidence and part of the Collector-owned canonical identity. `Foo`, `!Foo`, and `*Foo` therefore remain distinct observations. The draft schema rejects a `startup_kind` inconsistent with the final `Run`/`RunOnce` key-path component, a Run entry carrying RunOnce semantics, or a RunOnce interpretation inconsistent with its raw name. -Every Registry artifact includes lowercase SHA-256 of the complete native value bytes. This keeps two undecoded values distinguishable even when neither retains raw bytes, and keeps truncated prefixes from hiding a changed suffix. Optional raw evidence is limited to the first 4 KiB, records captured/original byte counts and truncation, and uses validated lowercase hex rather than JSON arrays of byte integers. Hex costs two JSON characters per byte but needs no ambiguous binary codec and remains substantially smaller than integer arrays. +Every Registry artifact includes lowercase SHA-256 of the complete native value bytes. This keeps two undecoded values distinguishable even when neither retains raw bytes. Optional raw evidence is schema-bounded to the first 4 KiB, records captured/original byte counts and truncation, and uses validated lowercase hex rather than JSON arrays of byte integers. Registry startup Collector v1 always emits `raw_evidence: null`: no concrete need currently outweighs the privacy and Snapshot-size cost of duplicating native bytes. + +Registry startup collection also applies explicit SystemDiff resource budgets: 8 MiB of native data per value, 32 MiB of retained native value-name and value-data evidence across the Collector, and 4,096 values per scope. These are capture limits, not Windows platform limits. Exceeding one creates a scoped diagnostic and `partial` coverage; a value without complete native evidence is not emitted or hashed as if complete, while fully captured sibling observations are retained. Hashes and raw prefixes are still sensitive: low-entropy values can be guessed, and Registry data may contain paths, usernames, commands, or secrets. Raw evidence is included only for a concrete forensic reason rather than duplicated for every decoded value, and both forms must be covered by redaction/share policy. @@ -78,9 +80,9 @@ The logical key is: collector_id + scope_id + artifact_kind + canonical_identity ``` -Collectors own canonicalization and version it through their Collector version. Raw casing and display values remain in evidence. Duplicate keys make the snapshot invalid for diffing. +Collectors own canonicalization and version it through their Collector version. Raw casing and display values remain in evidence. Duplicate keys make the snapshot invalid for diffing. Diagnostics may carry a `scope_id`; when present, it must reference one of the same Collector run's coverage scopes. A missing `scope_id` remains a collector-wide diagnostic. -For Registry startup entries, the complete raw value name participates in canonical identity. Prefixes are not stripped or normalized into a prefix-independent identity. +For Registry startup entries, Collector v1 computes a domain-separated SHA-256 over the exact value-name UTF-16 code units and their length. Prefixes and empty names are not stripped or normalized. Windows Registry value lookup itself is case-insensitive, but Microsoft does not provide a documented persistent canonical representation that can be generated independently in two Snapshots and compared cross-platform. Exact-code-unit identity is therefore a conservative pre-v0.1 limitation: it prevents false merges but could expose a casing-only logical update as Removed + Added if enumerated casing changes. This is not a permanent claim about Registry identity; changing the algorithm requires a new Collector version and regression fixtures. ## Diff semantics diff --git a/docs/roadmap.md b/docs/roadmap.md index f705eda..1c8df83 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,6 +10,8 @@ The roadmap is outcome-oriented. Dates are intentionally omitted until the proje Exit condition: the repository is understandable, reviewable, and ready for the first collector issue without pretending the product already works. +Status: complete. The first Collector phase is now underway. + ## v0.1: CLI evidence pipeline User outcome: @@ -22,7 +24,7 @@ systemdiff diff before.json after.json Required scope: -- Registry Run/RunOnce entries. +- Registry Run/RunOnce entries. **Implemented in the development CLI; pre-release validation continues.** - Windows services configuration. - Scheduled Tasks 2.0 configuration. - Versioned snapshot and diff JSON. diff --git a/docs/threat-model.md b/docs/threat-model.md index ecec8db..40d55aa 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -18,7 +18,7 @@ Out of scope: - defending evidence integrity after kernel compromise; - credential dumping, exploitation, persistence creation, AV/EDR bypass, or remediation features, which are prohibited by `docs/product-principles.md` and `AGENTS.md`; - cloud services, multi-tenancy, authentication, and telemetry, which do not exist in the MVP; -- a final assessment of Collector implementations that have not yet been written. +- a final assessment of the still-unimplemented Services and Scheduled Tasks Collectors. Assumptions: @@ -28,9 +28,11 @@ Assumptions: - reports may be shared publicly despite warnings; - the future desktop WebView is less trusted than the Rust core process. +The production CLI and Collector API remain read-only. A separate developer E2E harness can write one synthetic current-user Run value only after an environment-variable gate and explicit switch; it refuses an existing value and verifies exact type/data before cleanup. Default CI does not run it, and no Registry write binding is reachable from production Rust code. + Open questions that may change rankings: -- final Windows support matrix and installer/update mechanism; +- installer/update mechanism and any future expansion beyond the current Windows 10/Server version 1709 minimum; - whether snapshots will ever be cryptographically signed or compared across machines; - final Tauri command/capability set; - public security contact and release-signing process. @@ -50,7 +52,7 @@ Open questions that may change rankings: ### Data flows and trust boundaries -- Windows OS -> Windows collectors: registry bytes, service buffers, task COM/XML via local Win32/COM; current-token ACLs apply; safe wrappers must validate lengths, encodings, HRESULT/Win32 outcomes, and concurrent mutation. +- Windows OS -> Windows collectors: the implemented Registry adapter reads native buffers through current-token Win32 access; future service/task adapters will add SCM and COM. Safe wrappers validate lengths, encodings, numeric Win32 outcomes, resource budgets, and concurrent mutation. - Snapshot files -> CLI/core parser: attacker-controlled local JSON via file I/O; a 64 MiB bounded read and header-first schema route precede full Snapshot construction, while typed validation and identity uniqueness protect later processing. Finer object, string, nesting, and count limits remain future hardening. - Core evidence -> diff/rules: typed in-process values; compatibility, coverage, deterministic identity, and no evidence execution are the guarantees. - Diff/findings -> report files/terminal: privacy-sensitive local output; destination choice is user-controlled, and future sanitization must be explicit. @@ -134,9 +136,9 @@ flowchart LR | Threat ID | Threat source | Prerequisites | Threat action | Impact | Impacted assets | Existing controls (evidence) | Gaps | Recommended mitigations | Detection ideas | Likelihood | Impact severity | Priority | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| TM-001 | Local changed software | Can race, deny, or influence a collected scope | Cause incomplete evidence to appear complete | False Added/Removed and incorrect findings | Diff and coverage integrity | Explicit statuses and no-false-removal rule (`docs/adr/0004-collector-failure-and-coverage.md`) | Collectors not implemented | Per-scope coverage, bounded retry, native diagnostics, complete->partial regression fixtures | Count coverage changes and surface prominent report warnings | High | High | High | +| TM-001 | Local changed software | Can race, deny, or influence a collected scope | Cause incomplete evidence to appear complete | False Added/Removed and incorrect findings | Diff and coverage integrity | Registry scopes have bounded retries, before/after metadata checks, resource/permission diagnostics, and no-false-removal coverage semantics (`docs/adr/0004-collector-failure-and-coverage.md`) | Registry enumeration is best-effort, not atomic; later Collectors remain unimplemented | Preserve per-scope coverage, keep bounded native diagnostics, and add Collector-specific race fixtures | Count coverage changes and surface prominent report warnings | Medium | High | High | | TM-002 | Snapshot provider | Can supply arbitrary local JSON | Exhaust memory/CPU or exploit parser assumptions | CLI denial of service; misleading evidence | Availability and diff integrity | 64 MiB bounded file read, header-first version routing, typed schema, and duplicate rejection (`docs/data-format.md`) | No object-count, string-size, nesting, or parser-fuzz limits yet | Add finer structural limits only from measured need; fuzz parsers later | Structured parse failure codes; resource-limit tests | Medium | Medium | Medium | -| TM-003 | Windows data/API edge case | Collector handles raw buffers/COM values | Trigger unsafe length, lifetime, or encoding bug | Crash, memory corruption, corrupted evidence | Token, evidence, availability | Unsafe isolated to Windows crate (`docs/architecture.md`) | Native adapters absent | Minimal audited unsafe blocks, `windows-rs`, two-call buffer patterns, RAII handles/COM, fuzz pure decoders | Windows crash fixtures and sanitizer/fuzz jobs where practical | Medium | High | High | +| TM-003 | Windows data/API edge case | Collector handles raw buffers/COM values | Trigger unsafe length, lifetime, or encoding bug | Crash, memory corruption, corrupted evidence | Token, evidence, availability | Unsafe is isolated to a narrow `windows-rs` Registry adapter with checked units/lengths, initialized buffers, bounded growth, RAII handles, strict pure decoders, and full native-byte hashing (`docs/architecture.md`) | No fuzzing; Services/Tasks native adapters not implemented | Keep unsafe blocks minimal and reviewed; fuzz pure decoders; apply equivalent RAII/bounds discipline to future SCM/COM work | Windows regression fixtures and sanitizer/fuzz jobs where practical | Medium | High | High | | TM-004 | User/workflow mistake | Real report is shared publicly | Publish sensitive raw evidence | Lasting privacy disclosure | Snapshot/report confidentiality | Sensitive-by-default and redaction metadata (`docs/product-principles.md`) | Sanitizer absent | Blocking share warning in UI, documented manual review, policy-versioned pure sanitizer, synthetic issue fixtures | Scan project issues for accidental reports; sanitizer golden tests | High | High | High | | TM-005 | Future compromised WebView | Desktop exposes broad command/capability | Invoke native execution/write or read excess data | Privilege misuse and boundary violation | Token, host integrity, evidence confidentiality | Proposed narrow IPC (`docs/adr/0003-desktop-stack.md`) | Desktop not yet threat-tested | Bundled content, restrictive CSP, explicit commands, no generic shell/fs/http plugins, command authorization tests | Log command IDs without sensitive payloads; capability review in CI | Low pre-v0.2 | High | Medium | | TM-006 | Rule author or malformed evidence | Rule sees ambiguous command/path data | Overstate heuristic or detach finding from evidence | Misleading/fearmongering output | Finding integrity and user trust | Evidence-before-judgment principle; findings reference changes (`docs/architecture.md`) | No real rule corpus/reviewer rubric | Stable reason IDs, calibrated classifications, explanation keys, counterexample fixtures, independent review | Golden finding snapshots and rule precision review | Medium | Medium | Medium | diff --git a/fixtures/snapshots/after-v1.json b/fixtures/snapshots/after-v1.json index 0216cac..1af31b4 100644 --- a/fixtures/snapshots/after-v1.json +++ b/fixtures/snapshots/after-v1.json @@ -21,7 +21,27 @@ "status": "complete", "coverage": [ { - "scope_id": "current_user.shared", + "scope_id": "current_user.shared.run", + "status": "complete" + }, + { + "scope_id": "current_user.shared.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run_once", "status": "complete" } ], @@ -54,7 +74,8 @@ "code": "task_folder_access_denied", "message": "A synthetic folder was inaccessible.", "stage": "enumerate_folder", - "native_code": -2147024891 + "native_code": -2147024891, + "scope_id": "current_token" } ] } @@ -67,15 +88,18 @@ { "collector_id": "windows.registry.startup", "collector_version": 1, - "scope_id": "current_user.shared", - "canonical_id": "hkcu|shared|software\\microsoft\\windows\\currentversion\\run|exampleapp", + "scope_id": "current_user.shared.run", + "canonical_id": "05b14fce21e4232e9c47bc29ba58737949d4c17c78c8f2efb289128644c60603", "artifact": { "kind": "registry_startup", "evidence": { "hive": "current_user", "registry_view": "shared", "key_path": "Software\\Microsoft\\Windows\\CurrentVersion\\Run", - "value_name": "ExampleApp", + "value_name": { + "encoding": "decoded", + "value": "ExampleApp" + }, "startup_kind": "run", "run_once_prefix": null, "value_type": 1, @@ -94,15 +118,18 @@ { "collector_id": "windows.registry.startup", "collector_version": 1, - "scope_id": "current_user.shared", - "canonical_id": "hkcu|shared|software\\microsoft\\windows\\currentversion\\run|exampleupdater", + "scope_id": "current_user.shared.run", + "canonical_id": "380f5008f465773da732283e7e264b121cced755b2c7088abaf22137a03e37a9", "artifact": { "kind": "registry_startup", "evidence": { "hive": "current_user", "registry_view": "shared", "key_path": "Software\\Microsoft\\Windows\\CurrentVersion\\Run", - "value_name": "ExampleUpdater", + "value_name": { + "encoding": "decoded", + "value": "ExampleUpdater" + }, "startup_kind": "run", "run_once_prefix": null, "value_type": 1, diff --git a/fixtures/snapshots/before-v1.json b/fixtures/snapshots/before-v1.json index d9cf9a2..6bc6eb9 100644 --- a/fixtures/snapshots/before-v1.json +++ b/fixtures/snapshots/before-v1.json @@ -21,7 +21,27 @@ "status": "complete", "coverage": [ { - "scope_id": "current_user.shared", + "scope_id": "current_user.shared.run", + "status": "complete" + }, + { + "scope_id": "current_user.shared.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run_once", "status": "complete" } ], @@ -54,7 +74,8 @@ "code": "task_folder_access_denied", "message": "A synthetic folder was inaccessible.", "stage": "enumerate_folder", - "native_code": -2147024891 + "native_code": -2147024891, + "scope_id": "current_token" } ] } @@ -67,15 +88,18 @@ { "collector_id": "windows.registry.startup", "collector_version": 1, - "scope_id": "current_user.shared", - "canonical_id": "hkcu|shared|software\\microsoft\\windows\\currentversion\\run|exampleapp", + "scope_id": "current_user.shared.run", + "canonical_id": "05b14fce21e4232e9c47bc29ba58737949d4c17c78c8f2efb289128644c60603", "artifact": { "kind": "registry_startup", "evidence": { "hive": "current_user", "registry_view": "shared", "key_path": "Software\\Microsoft\\Windows\\CurrentVersion\\Run", - "value_name": "ExampleApp", + "value_name": { + "encoding": "decoded", + "value": "ExampleApp" + }, "startup_kind": "run", "run_once_prefix": null, "value_type": 1, diff --git a/fixtures/snapshots/registry-after-v1.json b/fixtures/snapshots/registry-after-v1.json new file mode 100644 index 0000000..89d977d --- /dev/null +++ b/fixtures/snapshots/registry-after-v1.json @@ -0,0 +1,85 @@ +{ + "document_type": "systemdiff.snapshot", + "schema_version": 1, + "systemdiff_version": "0.0.0-dev", + "captured_at": "2026-08-11T00:01:00Z", + "host": { + "windows_version": "synthetic-windows", + "windows_build": "26100", + "architecture": "x86_64" + }, + "privilege": "standard_user", + "enabled_collectors": [ + "windows.registry.startup" + ], + "collectors": [ + { + "id": "windows.registry.startup", + "version": 1, + "status": "complete", + "coverage": [ + { + "scope_id": "current_user.shared.run", + "status": "complete" + }, + { + "scope_id": "current_user.shared.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run_once", + "status": "complete" + } + ], + "diagnostics": [] + } + ], + "redaction": { + "status": "unredacted", + "policy": null + }, + "observations": [ + { + "collector_id": "windows.registry.startup", + "collector_version": 1, + "scope_id": "current_user.shared.run", + "canonical_id": "603cab62a78285fb7d508ee634283f1a83d391b0de70580443a14aa1ab38c474", + "artifact": { + "kind": "registry_startup", + "evidence": { + "hive": "current_user", + "registry_view": "shared", + "key_path": "Software\\Microsoft\\Windows\\CurrentVersion\\Run", + "value_name": { + "encoding": "decoded", + "value": "SystemDiffSyntheticE2E" + }, + "startup_kind": "run", + "run_once_prefix": null, + "value_type": 1, + "content_sha256": "f22ca9d0239764d62b2306f81fbe3c88cb5e9c9cc0306a9d61f6936766036357", + "decoding": { + "status": "decoded", + "value": { + "kind": "string", + "value": "C:\\Synthetic\\example.exe" + } + }, + "raw_evidence": null + } + } + } + ] +} diff --git a/fixtures/snapshots/registry-before-v1.json b/fixtures/snapshots/registry-before-v1.json new file mode 100644 index 0000000..05c34ab --- /dev/null +++ b/fixtures/snapshots/registry-before-v1.json @@ -0,0 +1,54 @@ +{ + "document_type": "systemdiff.snapshot", + "schema_version": 1, + "systemdiff_version": "0.0.0-dev", + "captured_at": "2026-08-11T00:00:00Z", + "host": { + "windows_version": "synthetic-windows", + "windows_build": "26100", + "architecture": "x86_64" + }, + "privilege": "standard_user", + "enabled_collectors": [ + "windows.registry.startup" + ], + "collectors": [ + { + "id": "windows.registry.startup", + "version": 1, + "status": "complete", + "coverage": [ + { + "scope_id": "current_user.shared.run", + "status": "complete" + }, + { + "scope_id": "current_user.shared.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry32.run_once", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run", + "status": "complete" + }, + { + "scope_id": "local_machine.registry64.run_once", + "status": "complete" + } + ], + "diagnostics": [] + } + ], + "redaction": { + "status": "unredacted", + "policy": null + }, + "observations": [] +} diff --git a/scripts/run-registry-startup-e2e.ps1 b/scripts/run-registry-startup-e2e.ps1 new file mode 100644 index 0000000..d600259 --- /dev/null +++ b/scripts/run-registry-startup-e2e.ps1 @@ -0,0 +1,298 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [switch]$ConfirmSyntheticRegistryTest, + + [switch]$RecoveryOnly, + + [string]$SyntheticValueName +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($env:SYSTEMDIFF_RUN_SYNTHETIC_E2E -ne '1' -or -not $ConfirmSyntheticRegistryTest) { + throw 'Refusing to write test Registry evidence. Set SYSTEMDIFF_RUN_SYNTHETIC_E2E=1 and pass -ConfirmSyntheticRegistryTest.' +} + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$cargoCommand = Get-Command cargo -ErrorAction SilentlyContinue +$cargo = if ($null -ne $cargoCommand) { + $cargoCommand.Source +} +else { + Join-Path $env:USERPROFILE '.cargo\bin\cargo.exe' +} +if (-not (Test-Path -LiteralPath $cargo -PathType Leaf)) { + throw "Cargo was not found at '$cargo'." +} + +$keyPath = 'Software\Microsoft\Windows\CurrentVersion\Run' +$syntheticNamePattern = '^SystemDiffSyntheticE2E-[0-9a-f]{32}$' +if ($RecoveryOnly -and [String]::IsNullOrWhiteSpace($SyntheticValueName)) { + throw 'Recovery requires -SyntheticValueName with the exact name printed by the original run.' +} +$valueName = if ([String]::IsNullOrWhiteSpace($SyntheticValueName)) { + 'SystemDiffSyntheticE2E-' + [Guid]::NewGuid().ToString('N') +} +else { + $SyntheticValueName +} +if ($valueName -cnotmatch $syntheticNamePattern) { + throw "Synthetic value name '$valueName' does not match the guarded SystemDiff E2E format." +} +$expectedData = '"' + (Join-Path $env:SystemRoot 'System32\cmd.exe') + '" /d /c exit 0' +$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("SystemDiffRegistryE2E-{0}" -f [Guid]::NewGuid().ToString('N')) +$beforePath = Join-Path $temporaryRoot 'before.json' +$afterPath = Join-Path $temporaryRoot 'after.json' +$cleanupEligible = $false +$cleanupVerified = $false +$temporaryCleanupVerified = $false + +Write-Output "Synthetic value name: $valueName" +Write-Warning 'Snapshot files contain privacy-sensitive local evidence. This harness deletes them after validation; do not upload recovered files without review and sanitization.' + +function Get-MatchingValueName { + param( + [Parameter(Mandatory = $true)] + [Microsoft.Win32.RegistryKey]$Key, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + foreach ($candidate in $Key.GetValueNames()) { + if ([StringComparer]::OrdinalIgnoreCase.Equals($candidate, $Name)) { + return $candidate + } + } + return $null +} + +function Invoke-SystemDiff { + param( + [Parameter(Mandatory = $true)] + [string]$Executable, + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + $output = & $Executable @Arguments + if ($LASTEXITCODE -ne 0) { + throw "SystemDiff exited with code $LASTEXITCODE while running: $($Arguments -join ' ')" + } + return $output +} + +if ($RecoveryOnly) { + $recoveryKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($keyPath, $true) + if ($null -eq $recoveryKey) { + throw 'Recovery failed: the HKCU Run key could not be opened.' + } + try { + $actualName = Get-MatchingValueName -Key $recoveryKey -Name $valueName + if ($null -eq $actualName) { + Write-Output 'Recovery: synthetic Registry value already absent' + return + } + $actualKind = $recoveryKey.GetValueKind($actualName) + $actualData = $recoveryKey.GetValue($actualName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($actualKind -ne [Microsoft.Win32.RegistryValueKind]::String -or $actualData -cne $expectedData) { + throw "Recovery refused: '$actualName' does not have the exact known synthetic type and data." + } + $recoveryKey.DeleteValue($actualName, $true) + if ($null -ne (Get-MatchingValueName -Key $recoveryKey -Name $valueName)) { + throw 'Recovery failed: the synthetic Registry value still exists.' + } + Write-Output 'Recovery: exact-data guarded deletion verified' + return + } + finally { + $recoveryKey.Dispose() + } +} + +New-Item -ItemType Directory -Path $temporaryRoot -ErrorAction Stop | Out-Null + +try { + $preflightKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($keyPath, $false) + if ($null -eq $preflightKey) { + throw "The HKCU Run key does not exist; the harness will not create it." + } + try { + if ($null -ne (Get-MatchingValueName -Key $preflightKey -Name $valueName)) { + throw "Refusing to overwrite existing Registry value '$valueName'." + } + } + finally { + $preflightKey.Dispose() + } + + Push-Location $repositoryRoot + try { + & $cargo build --locked -p systemdiff-cli + if ($LASTEXITCODE -ne 0) { + throw "Cargo build failed with code $LASTEXITCODE." + } + } + finally { + Pop-Location + } + + $systemdiff = Join-Path $repositoryRoot 'target\debug\systemdiff.exe' + if (-not (Test-Path -LiteralPath $systemdiff -PathType Leaf)) { + throw "Built SystemDiff executable was not found at '$systemdiff'." + } + + Invoke-SystemDiff -Executable $systemdiff -Arguments @('snapshot', '-o', $beforePath) | Out-Null + + $writeKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($keyPath, $true) + if ($null -eq $writeKey) { + throw 'The HKCU Run key could not be opened for the explicitly gated test mutation.' + } + try { + if ($null -ne (Get-MatchingValueName -Key $writeKey -Name $valueName)) { + throw "Registry value '$valueName' appeared after preflight; refusing to overwrite it." + } + $cleanupEligible = $true + $writeKey.SetValue($valueName, $expectedData, [Microsoft.Win32.RegistryValueKind]::String) + $actualName = Get-MatchingValueName -Key $writeKey -Name $valueName + if ($null -eq $actualName) { + throw 'Synthetic Registry value was not observable immediately after the test write.' + } + $actualKind = $writeKey.GetValueKind($actualName) + $actualData = $writeKey.GetValue($actualName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($actualKind -ne [Microsoft.Win32.RegistryValueKind]::String -or $actualData -cne $expectedData) { + throw 'Synthetic Registry value did not round-trip with the exact expected type and data.' + } + } + finally { + $writeKey.Dispose() + } + + Invoke-SystemDiff -Executable $systemdiff -Arguments @('snapshot', '-o', $afterPath) | Out-Null + $diffText = (Invoke-SystemDiff -Executable $systemdiff -Arguments @('diff', '--json', $beforePath, $afterPath)) -join [Environment]::NewLine + $diff = $diffText | ConvertFrom-Json + $changes = @($diff.changes) + $added = @($changes | Where-Object { $_.change.change -eq 'added' }) + $removed = @($changes | Where-Object { $_.change.change -eq 'removed' }) + + if ($changes.Count -ne 1 -or $added.Count -ne 1 -or $removed.Count -ne 0) { + throw "Expected exactly one Added change and no other changes; observed $($changes.Count) total, $($added.Count) Added, and $($removed.Count) Removed." + } + + $change = $added[0] + $evidence = $change.change.after.evidence + if ( + $change.key.collector_id -ne 'windows.registry.startup' -or + $change.key.scope_id -ne 'current_user.shared.run' -or + $change.change.after.kind -ne 'registry_startup' -or + $evidence.hive -ne 'current_user' -or + $evidence.registry_view -ne 'shared' -or + $evidence.startup_kind -ne 'run' -or + $evidence.value_name.encoding -ne 'decoded' -or + $evidence.value_name.value -cne $valueName + ) { + throw 'The Added change did not match the exact synthetic HKCU Shared Run evidence identity.' + } + + Write-Output 'Before Snapshot: synthetic value absent' + Write-Output 'Synthetic mutation: exact HKCU Shared Run REG_SZ established' + Write-Output 'After Snapshot: synthetic value present' + Write-Output 'Diff: exactly 1 Added, 0 Removed, expected identity matched' +} +finally { + $cleanupFailure = $null + if ($cleanupEligible) { + try { + $cleanupKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($keyPath, $true) + if ($null -eq $cleanupKey) { + throw 'Cleanup failed: the HKCU Run key could not be opened.' + } + try { + $actualName = Get-MatchingValueName -Key $cleanupKey -Name $valueName + if ($null -eq $actualName) { + $cleanupVerified = $true + } + else { + $actualKind = $cleanupKey.GetValueKind($actualName) + $actualData = $cleanupKey.GetValue($actualName, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($actualKind -ne [Microsoft.Win32.RegistryValueKind]::String -or $actualData -cne $expectedData) { + throw "Cleanup refused: '$actualName' no longer has the exact synthetic type and data." + } + $cleanupKey.DeleteValue($actualName, $true) + $cleanupVerified = $null -eq (Get-MatchingValueName -Key $cleanupKey -Name $valueName) + if (-not $cleanupVerified) { + throw 'Cleanup failed: the synthetic Registry value still exists.' + } + } + } + finally { + $cleanupKey.Dispose() + } + } + catch { + $cleanupFailure = $_.Exception.Message + } + } + + try { + $resolvedTemporaryRoot = [System.IO.Path]::GetFullPath($temporaryRoot) + $expectedTemporaryPrefix = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + if (-not $resolvedTemporaryRoot.StartsWith($expectedTemporaryPrefix, [StringComparison]::OrdinalIgnoreCase) -or + -not ([System.IO.Path]::GetFileName($resolvedTemporaryRoot)).StartsWith('SystemDiffRegistryE2E-', [StringComparison]::Ordinal)) { + throw "Refusing to remove unexpected temporary path '$resolvedTemporaryRoot'." + } + if ([System.IO.Directory]::Exists($resolvedTemporaryRoot)) { + $directoryAttributes = [System.IO.File]::GetAttributes($resolvedTemporaryRoot) + if (($directoryAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing to remove reparse-point temporary directory '$resolvedTemporaryRoot'." + } + $allowedFiles = @( + [System.IO.Path]::GetFullPath($beforePath), + [System.IO.Path]::GetFullPath($afterPath) + ) + foreach ($entry in [System.IO.Directory]::EnumerateFileSystemEntries($resolvedTemporaryRoot)) { + $resolvedEntry = [System.IO.Path]::GetFullPath($entry) + if ($allowedFiles -notcontains $resolvedEntry) { + throw "Refusing to remove unexpected temporary entry '$resolvedEntry'." + } + $entryAttributes = [System.IO.File]::GetAttributes($resolvedEntry) + if (($entryAttributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 -or + ($entryAttributes -band [System.IO.FileAttributes]::Directory) -ne 0) { + throw "Refusing to remove unexpected temporary entry type '$resolvedEntry'." + } + } + foreach ($knownFile in $allowedFiles) { + if ([System.IO.File]::Exists($knownFile)) { + [System.IO.File]::Delete($knownFile) + } + } + [System.IO.Directory]::Delete($resolvedTemporaryRoot, $false) + } + $temporaryCleanupVerified = -not [System.IO.Directory]::Exists($resolvedTemporaryRoot) + if (-not $temporaryCleanupVerified) { + throw 'Temporary Snapshot directory still exists after cleanup.' + } + } + catch { + $temporaryFailure = $_.Exception.Message + $cleanupFailure = if ($null -eq $cleanupFailure) { + $temporaryFailure + } + else { + "$cleanupFailure Temporary-file cleanup also failed: $temporaryFailure" + } + } + if ($null -ne $cleanupFailure) { + throw $cleanupFailure + } +} + +if (-not $cleanupVerified) { + throw 'Synthetic Registry cleanup was not verified.' +} +Write-Output 'Cleanup: exact-data guarded deletion verified' +if (-not $temporaryCleanupVerified) { + throw 'Temporary Snapshot cleanup was not verified.' +} +Write-Output 'Temporary Snapshots: deletion verified'