From c562dbf379bc71215ae5fff3986d819f572ed8a0 Mon Sep 17 00:00:00 2001 From: Xiaoju Date: Thu, 13 Aug 2026 13:28:58 +0800 Subject: [PATCH] feat(cli): add human-readable Registry diff output --- .agent/PROJECT_STATE.md | 79 +- .agent/plans/human-readable-diff.md | 144 +++ Cargo.lock | 1 + README.md | 111 ++- README.zh-CN.md | 123 ++- crates/systemdiff-cli/src/main.rs | 48 +- crates/systemdiff-report/Cargo.toml | 1 + crates/systemdiff-report/src/lib.rs | 931 ++++++++++++++++++- crates/systemdiff-report/tests/human_diff.rs | 617 ++++++++++++ docs/architecture.md | 8 +- docs/assets/README.md | 13 + docs/assets/registry-startup-demo.svg | 25 + fixtures/reports/registry-added-human.txt | 14 + scripts/run-registry-startup-e2e.ps1 | 22 + 14 files changed, 1935 insertions(+), 202 deletions(-) create mode 100644 .agent/plans/human-readable-diff.md create mode 100644 crates/systemdiff-report/tests/human_diff.rs create mode 100644 docs/assets/README.md create mode 100644 docs/assets/registry-startup-demo.svg create mode 100644 fixtures/reports/registry-added-human.txt diff --git a/.agent/PROJECT_STATE.md b/.agent/PROJECT_STATE.md index d64dc2d..8e9a3fb 100644 --- a/.agent/PROJECT_STATE.md +++ b/.agent/PROJECT_STATE.md @@ -1,73 +1,56 @@ # Project state -Last updated: 2026-08-11 +Last updated: 2026-08-13 ## Current phase -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. +The public foundation, pre-collector hardening, and first real Windows product slice are on `main`. PR #6 is merged: SystemDiff can capture Registry Run/RunOnce startup evidence, write a Snapshot, and compare a real before/after pair. The current `feat/human-readable-diff` work for Issue #7 is the first product-presentation pass: default human-readable Diff output, an explicit technical text mode, and a truthful stranger-first repository front page. 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, 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. -- GitHub Secret Scanning, Push Protection, and Private Vulnerability Reporting are enabled. -- Registry evidence keeps the native type code, validated typed decode status/value, a full-content SHA-256, and an optional validated 4 KiB lowercase-hex raw prefix rather than assuming every value is a UTF-16LE string. -- 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. +- Public repository `XiaojuCH/SystemDiff` with Apache-2.0 licensing, contributor/security documentation, issue forms, required Windows/Ubuntu CI, Dependabot, Secret Scanning, Push Protection, Private Vulnerability Reporting, and an active `main` ruleset. +- Rust workspace boundaries for versioned evidence, Windows collection, deterministic Diff, rules, reports, and CLI composition. +- Draft v1 Snapshot and Diff documents with bounded/header-first Snapshot input, strict UTC timestamps, deterministic serialization, and deliberate Collector-version compatibility checks. +- Coverage-aware comparison: incomplete, unavailable, unsupported, or permission-denied scope coverage cannot silently become a Removed finding. +- `windows.registry.startup` v1 using query-only Win32 Registry APIs, explicit Registry views, scoped diagnostics, bounded mutation/resource handling, strict native-data decoding, lossless UTF-16 value names, and complete-value SHA-256. +- `systemdiff snapshot -o ` with canonical UTC metadata, bounded serialization, and create-new output semantics. +- Default human-readable Registry Diff output, explicit `--technical` evidence output, and unchanged `--json` machine output on the current Issue #7 feature branch. +- Registry-only synthetic before/after fixtures and a dual-gated test-only real HKCU E2E. The real E2E observed exactly one expected Added startup value, zero Removed changes, and verified exact-data cleanup; production Rust has no Registry write path. +- A truthful Registry-only README demo whose transcript is regression-tested and whose static visual is derived from that exact output. +- Project-scoped Codex agents, three repeated-workflow skills, living ExecPlans, architecture/format/Collector/threat-model documentation, and synthetic cross-platform tests. ## Known limitations -- 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. -- Draft fixtures and wire types may change before v0.1; after v0.1, v1 compatibility becomes a release obligation. -- Draft v0.1 diffs assume the same Windows installation and the same user/principal context. Cross-host and cross-user identity are out of scope; no SID hash, machine token, or identity framework exists. -- No dedicated private Code of Conduct reporting channel is published. GitHub Private Vulnerability Reporting is available only for product security reports. +- Only Registry Run/RunOnce collection is implemented. Services, Scheduled Tasks, rules/explanations, sanitization, installation/package delivery, and the desktop app are unavailable. +- There is no official binary release. Current users must build the development CLI from source. +- The current minimum is Windows 10 version 1709 or Windows Server 2016 version 1709. ARM64 v1 collects HKCU Shared scopes but reports HKLM alternate-view coverage as unsupported until those views are represented and tested. +- Snapshot files are unredacted and can contain usernames in paths, command strings, software details, and other sensitive host evidence. They must be reviewed before sharing. +- Draft v0.1 diffs assume the same Windows installation and the same user/principal context. Cross-host and cross-user identity are intentionally out of scope. +- Registry lookup is case-insensitive, but Collector v1 identity uses exact UTF-16 units because no documented durable cross-platform canonical token is available. A returned casing change can appear as a visible Removed + Added pair. +- The desktop app is a proposed future boundary, not a generated Tauri application. +- No dedicated private Code of Conduct reporting channel is published. GitHub Private Vulnerability Reporting is only for product security reports. - No CODEOWNERS file is committed during the solo-maintainer stage. ## Decisions affecting current work -- Rust owns the shared domain, diff, rule, and reporting logic; Windows API access is isolated in one crate. -- Tauri 2 with React and TypeScript is proposed for v0.2 and will be validated after the CLI MVP. -- Snapshot and diff JSON are separately versioned documents with deterministic serialization expectations. -- Snapshot files are capped at 64 MiB at the CLI boundary and routed by a minimal core header before v1 body construction; no configurable resource policy or migration registry exists. -- Valid read-time `captured_at` values must be RFC 3339 known UTC using `Z` or `+00:00`; future SystemDiff writers will emit canonical `Z`. -- Collector failures and privilege limitations are recorded per collector/scope and must not invalidate unrelated evidence. -- 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. -- Normal changes to `main` go through pull requests and the active required checks; maintainer bypass is reserved for exceptional recovery. +- SystemDiff remains offline-first and read-only; evidence is never executed, remediated, or uploaded by default. +- Core and JSON identifiers remain language-neutral. Human presentation is layered over exact technical text and versioned JSON rather than replacing evidence. +- Default terminal output is plain text with no ANSI/color dependency. Snapshot-derived control characters are escaped before display. +- Technical text rendering receives the validated before/after Snapshots so Collector versions and diagnostics are reported from evidence rather than inferred; the Diff v1 JSON schema remains unchanged. +- Snapshot files are capped at 64 MiB at the CLI boundary. Registry capture limits are 8 MiB native data per value, 32 MiB retained name-and-value evidence per Collector run, and 4,096 values per scope. These are SystemDiff resource limits, not Windows platform limits. +- Unknown cross-version comparisons for the same Collector ID are rejected by default. A future verified compatible pair remains possible, but no migration framework exists. +- Registry views, RunOnce prefixes, and value names retain their documented/evidence semantics; no command parsing, environment expansion, executable resolution, signature check, or risk inference occurs. +- Normal changes to `main` go through pull requests and the two required checks: `Rust (windows-latest)` and `Rust (ubuntu-latest)`. ## Next milestone -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. +Finish review and CI for Issue #7 without merging automatically. After that, plan the lightest credible portable developer preview so strangers can try the real Registry workflow without a Rust toolchain. Windows Services remains the next Collector candidate, but it is not started in the presentation PR. ## Major unresolved questions - What genuine, monitored private channel should receive Code of Conduct reports? -- 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 publisher-signing and portable-build process can support a trustworthy first Windows developer preview? +- What explicitly versioned identity upgrade should eventually address Registry value-name casing without hiding raw evidence or coupling Diff to mutable 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/human-readable-diff.md b/.agent/plans/human-readable-diff.md new file mode 100644 index 0000000..75ee848 --- /dev/null +++ b/.agent/plans/human-readable-diff.md @@ -0,0 +1,144 @@ +# Human-readable Diff and stranger-first product presentation + +Status: In progress +Owner: Codex +Last updated: 2026-08-13 + +## Goal + +Deliver GitHub issue [#7](https://github.com/XiaojuCH/SystemDiff/issues/7): turn the implemented Registry Run/RunOnce Snapshot -> Diff path into a calm, readable default CLI experience and make the repository front page demonstrate that real capability truthfully. Preserve exact evidence through an explicit technical text mode and the unchanged deterministic JSON mode. + +## User-visible outcome + +Users can run: + +```text +systemdiff diff before.json after.json +systemdiff diff --technical before.json after.json +systemdiff diff --json before.json after.json +``` + +The default command identifies recognizable startup entries, what factually changed, decoded command/value data when available, and the Registry location. `--technical` exposes Collector identity/version, scope, canonical identity, native Registry evidence, hashes, decode status, and coverage diagnostics. `--json` retains the existing v1 wire contract byte-for-byte for an unchanged Diff value. + +The English and Chinese READMEs lead with the long-term user problem, immediately disclose the current Registry-only preview scope, and show a static terminal visual derived from verified CLI output rather than an aspirational mock. + +## Current architecture and context + +- `main` at `0113b3e08811451bb580263b7b1c2db4fb7758b3` contains the merged Registry startup Collector, `snapshot` CLI, coverage-aware Diff, deterministic JSON, and a dual-gated real Windows E2E harness. Both required checks are green. +- `systemdiff-core` owns typed Registry startup artifacts, lossless Registry value names, decoded values, collector coverage, and scoped diagnostics. +- `systemdiff-diff` owns Added/Removed/Modified/Unchanged/Inconclusive classification and warnings. It intentionally omits Snapshot-only Collector version and diagnostic detail. +- `systemdiff-report` currently renders a terse ArtifactKey-oriented terminal list and generic pretty JSON. +- `systemdiff-cli` currently selects default terminal or `--json`; it already loads both validated Snapshots before diffing. +- Broad historical fixtures include service/task artifacts for schema and Diff coverage, but the truthful product demo uses the dedicated Registry-only fixtures. + +The existing default renderer is unsuitable for ordinary users because it leads with opaque Collector/scope/canonical hashes, exposes no recognizable Registry name or command, provides no before/after value for modifications, and formats coverage as debug enums. Typed Registry fields can safely support factual descriptions of startup kind, hive/user scope, exact key location, lossless/display name, decoded value, and change kind. They do not support severity, maliciousness, signature, executable identity, or remediation claims. + +## Constraints + +- No Services or Scheduled Tasks Collector, session/baseline/history workflow, release packaging, schema version, rule/risk logic, signing, sanitizer, GUI, installer, updater, or package-manager work. +- Do not claim `suspicious`, `safe`, `unsigned`, severity, threat, or any other inference not present in evidence. +- Preserve no-false-removal semantics: only confirmed `Removed` is described as removed; `Inconclusive` remains explicitly uncertain. +- Do not weaken, transform, localize, or otherwise change the Diff JSON wire contract. +- Terminal output must remain readable without color and contain no ANSI escapes. Untrusted observed strings must not inject control sequences or new output lines. +- Technical output must derive Collector version and diagnostics from the supplied Snapshots, not infer them from an ID or hard-code Registry v1. +- Human output may omit technical evidence from the first layer, but `--technical` and `--json` must keep it inspectable. +- README claims must distinguish implemented Registry startup coverage from planned Services/Tasks and must not offer a nonexistent binary download. +- Real snapshots remain sensitive and unredacted; documentation must keep the public-sharing warning. + +## Implementation steps + +1. Add focused report tests first for Added Run, Added RunOnce, Modified decoded value, Removed, Inconclusive, undecoded data, invalid UTF-16 name, unnamed value, grouping, no changes, coverage warnings, plain redirected output, technical evidence, and unchanged JSON serialization. +2. Replace the default ArtifactKey list with a deterministic human renderer grouped by evidence category. Registry items use only typed evidence and calm factual wording. Unsupported artifact families receive a minimal typed fallback without pretending their Collectors are implemented. +3. Add a technical renderer that accepts the Diff plus both source Snapshots. Render exact Registry fields and locate Collector version/diagnostics from Snapshot coverage/observations. Escape observed control characters in both modes. +4. Add `diff --technical`, make it mutually exclusive with `--json`, keep the default route human, and retain `--include-unchanged` behavior. +5. Run the Registry-only synthetic fixture through all three modes. Save one representative default transcript as a narrow golden fixture and build a clearly labeled static SVG from that exact output. +6. Redesign the first screen of `README.md` and `README.zh-CN.md` around the real workflow, truthful visual, current/planned capability table, trust facts, and real CTAs. Keep factual parity and natural Chinese. +7. Update the reporting boundary documentation, demo reproduction notes, this plan, and `.agent/PROJECT_STATE.md` to the merged PR #6 and current productization phase. +8. Run formatting, Clippy, all workspace tests, CLI human/technical/JSON/collectors smoke tests, link/parity/stale-wording checks, and the existing dual-gated real Windows E2E only if it remains safely runnable without touching non-synthetic evidence. +9. Request an independent reviewer pass covering correctness, evidence completeness, terminal safety, JSON compatibility, stranger-first comprehension, privacy, and scope. Address all High/Medium findings and only actionable Low findings. +10. Inspect the final diff, commit, push `feat/human-readable-diff`, create a PR closing #7, and wait for both required GitHub checks. Do not merge. + +## Affected files and modules + +- `crates/systemdiff-report/src/lib.rs` and focused report tests/fixtures +- `crates/systemdiff-report/Cargo.toml` for the existing internal `systemdiff-core` workspace dependency needed by technical Snapshot context +- `crates/systemdiff-cli/src/main.rs` for `--technical` routing +- `README.md`, `README.zh-CN.md` +- a truthful asset and reproduction note under `docs/assets/` +- `docs/architecture.md` if needed to describe the three report modes +- `.agent/PROJECT_STATE.md` +- this ExecPlan + +No Rust source outside report/CLI is expected to change. No schema, Collector, Windows adapter, Diff algorithm, rules, fixture Snapshot contract, CI behavior, or release configuration changes are expected. + +## Test strategy + +- Construct validated Registry-only fixture Diffs for each change kind and evidence edge case. +- Assert semantic sections and critical phrases rather than snapshotting every renderer permutation. +- Keep one short representative human transcript as a golden fixture; use the same transcript as the hero visual source. +- Assert human output contains recognizable value names/locations and no opaque SHA/ArtifactKey by default. +- Assert untrusted newline/escape/control characters are escaped and output contains no ANSI escape byte. +- Assert technical output contains change type, Collector ID/version, scope, artifact kind, canonical identity, hive/view/path, lossless name, native type, decode status, decoded before/after values, SHA-256, and matching scoped diagnostics. +- Assert `--technical` parses, `--json` parses, and the two flags conflict. +- Assert `write_json` output still equals `serde_json::to_string_pretty(diff) + "\n"` and existing deterministic Diff tests remain unchanged. +- Run the full workspace gates and three Registry-only CLI report modes. If the guarded E2E is rerun, require one synthetic Added change, zero Removed changes, and verified exact-data cleanup. + +## Risks + +- **False security interpretation:** readable prose may sound like a verdict. Limit copy to direct facts from typed evidence and test prohibited vocabulary. +- **Evidence loss in technical mode:** Diff alone lacks version/diagnostics. Pass both validated Snapshots and test lookup behavior rather than guessing. +- **Terminal injection:** Registry names/values are untrusted. Escape control characters while preserving readable Unicode and Windows path separators. +- **False removal under incomplete coverage:** keep Diff classification authoritative and never reinterpret Inconclusive as Removed. +- **README capability inflation:** use only Registry-only fixtures/output and clearly mark Services/Tasks planned. +- **Brittle presentation tests:** use semantic assertions plus one small representative golden, not a whole CLI corpus. +- **Cross-platform formatting drift:** avoid colors, terminal-width detection, locale-dependent output, and debug formatting. + +## Rollback and compatibility + +The change is additive at the CLI (`--technical`) and changes only the human terminal presentation of the unreleased development CLI. `--json` and both document schemas remain unchanged. Reverting the PR restores the earlier terminal renderer and README without affecting Snapshot/Diff compatibility. No migration framework or ADR is required. + +## Progress + +- [x] 2026-08-13: Synchronized clean `main`, confirmed local/remote SHA `0113b3e`, no open PR, and green latest CI. +- [x] 2026-08-13: Confirmed no equivalent Issue and created focused Issue #7. +- [x] 2026-08-13: Created `feat/human-readable-diff` from current `main`. +- [x] 2026-08-13: Read project state, architecture/data-format/Collector/threat/product docs, ADRs, implementation, tests, fixtures, and both READMEs. +- [x] 2026-08-13: Completed independent exploration and test-strategy synthesis; confirmed that technical version/diagnostic evidence requires source Snapshot context without changing Diff v1. +- [x] 2026-08-13: Implemented default human, explicit technical, and unchanged JSON routing with control/bidi escaping and focused behavior tests. +- [x] 2026-08-13: Produced a regression-tested Registry-only transcript and visually verified a static SVG rendered from that exact output; refreshed English/Chinese READMEs, architecture, and project state. +- [x] 2026-08-13: Ran full local gates and the dual-gated real Windows HKCU E2E; one synthetic Added change, zero Removed changes, both text renderers, exact-data cleanup, and Snapshot deletion were verified. +- [x] 2026-08-13: Addressed three Medium and one Low independent-review findings; final reviewer result is High 0 / Medium 0 / actionable Low 0. +- [ ] Commit, push, create the PR, and wait for both required GitHub checks. + +## Discoveries + +- Collector version and scoped diagnostic detail are Snapshot data and are not carried in `DiffDocument`. A complete technical text report needs source Snapshot context, while JSON output remains the Diff document only. +- Registry commands are only safely labelable as `Command` for decoded string/expand-string data. Numeric, multi-string, undecoded, or unsupported evidence must be described as a Registry value without guessing execution semantics. +- Registry evidence can contain terminal control characters; renderer escaping is a safety property, not cosmetic formatting. +- The existing Registry-only before/after fixtures are sufficient for a truthful product demo and avoid implying that Services or Scheduled Tasks collection exists. +- A Modified Registry artifact does not necessarily mean its decoded command text changed; human wording must compare the decoded strings before making that narrower claim. +- Exact technical evidence includes value boundaries, so `REG_MULTI_SZ` elements require count/index/quoted rendering rather than ambiguous delimiter joining. + +## Decisions + +- Keep plain output with no color dependency or ANSI behavior. Symbols and words carry all meaning. +- Keep `write_terminal` as the default human API for compatibility and add an explicit technical writer that receives both source Snapshots. +- Use typed, factual prose only; no rule engine or path/name heuristics. +- Represent an unnamed Registry value as `Default value (unnamed)` and an invalid UTF-16 name as an explicit undecodable-name label in human mode, with lossless UTF-16LE hex in technical mode. +- Use a static terminal-style SVG fallback, clearly labeled synthetic, because it can be reviewed and kept exactly synchronized with a verified transcript without faking an animation. + +## Final validation + +Local validation completed on Windows 2026-08-13: + +- `cargo fmt --all --check`: passed. +- `cargo clippy --locked --workspace --all-targets -- -D warnings`: passed. +- `cargo test --locked --workspace --all-targets`: 88 passed, 0 failed, 0 ignored. +- Registry-only human, technical, JSON, and `collectors` CLI smoke commands: exit 0. Human output reported one confirmed `SystemDiffSyntheticE2E` current-user Run addition; technical output retained Collector v1, scope, canonical identity, Registry evidence, SHA-256, and complete coverage; JSON remained the v1 Diff document. +- Existing broad-fixture human/JSON commands used by CI: exit 0. +- Dual-gated real Windows HKCU E2E: the randomized synthetic value was absent before, present after, classified as exactly one Added and zero Removed changes, matched by both human and technical renderers, removed only after exact type/data verification, and confirmed absent. Both temporary Snapshots were deleted. +- `git diff --check`, local Markdown links, English/Chinese factual-parity markers, stale/aspirational wording scan, secret/local-path scan, and SVG XML parsing passed. +- The SVG was rendered headlessly to PNG and inspected visually; the temporary PNG was removed and is not a repository artifact. +- Independent review initially found three Medium and one actionable Low issue. All were fixed with regressions; the focused final review reported High 0 / Medium 0 / actionable Low 0. + +Commit/PR identifiers and required remote check results are intentionally recorded in the PR and maintainer report because a commit cannot contain its own final hash or future CI result. diff --git a/Cargo.lock b/Cargo.lock index 3d944ff..f2c8dff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,6 +341,7 @@ version = "0.0.0" dependencies = [ "serde", "serde_json", + "systemdiff-core", "systemdiff-diff", ] diff --git a/README.md b/README.md index 67e9be4..61a547e 100644 --- a/README.md +++ b/README.md @@ -4,57 +4,60 @@ [![CI](https://github.com/XiaojuCH/SystemDiff/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/XiaojuCH/SystemDiff/actions/workflows/ci.yml) -**SystemDiff shows what changed on a Windows system, with plain-language explanations backed by inspectable evidence.** +## See what apps, installers, and scripts change on Windows. + +**Offline-first · Read-only · No account · No telemetry** + +SystemDiff takes a before Snapshot and an after Snapshot, then explains the evidence that changed. It is for questions like: “I installed this program—what did it add to startup?” > [!IMPORTANT] -> 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. +> SystemDiff is pre-release, source-build-only software. Today it captures and compares the documented Windows Registry Run/RunOnce startup locations. Services, Scheduled Tasks, rules, redaction, releases, and the desktop app are not implemented. + +[Try the sample](#try-the-registry-demo) · [Build from source](#build-from-source) · [Inspect the data format](docs/data-format.md) -## Why SystemDiff? +![SystemDiff showing one synthetic Registry startup entry added](docs/assets/registry-startup-demo.svg) -The canonical workflow is deliberately simple: +_Verified output from the committed synthetic Registry-only fixtures. No real host data is shown._ -1. Take snapshot A. -2. Install, run, or change something. -3. Take snapshot B. -4. Compare the snapshots. -5. Understand exactly what changed. +## Available today -Ordinary users should see a calm explanation: +| Capability | Status | +| --- | --- | +| Capture current-user and local-machine Run/RunOnce evidence | Implemented on supported Windows systems | +| Human-readable, technical, and deterministic JSON Diff output | Implemented | +| Coverage-aware comparison that does not turn missing evidence into a false removal | Implemented | +| Windows Services Collector | Planned next Collector; not implemented | +| Scheduled Tasks Collector | Planned; not implemented | +| Rules, signatures, risk classification, and redacted sharing | Planned; not implemented | -```text -High attention +SystemDiff reports facts such as “Added to current-user startup.” It does not currently decide whether an entry is malicious, safe, signed, or worthy of removal. -ExampleUpdater - Added itself to startup - Runs automatically when Windows starts - File is not digitally signed - Location: AppData - Open technical details +## Try the Registry demo -Normal +With a stable Rust MSVC toolchain installed: -ExampleApp settings - Added application configuration - Usually harmless +```powershell +cargo run --locked --quiet -p systemdiff-cli -- diff fixtures/snapshots/registry-before-v1.json fixtures/snapshots/registry-after-v1.json ``` -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. +The sample contains one synthetic `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` addition. It is the same output shown above and is fixed by a regression test. -SystemDiff does not equate unusual with malicious. Explanations sit on top of evidence; they never replace it. +The three report modes serve different needs: -## Trust model +```powershell +# Calm, readable summary +systemdiff diff before.json after.json + +# Exact text evidence for power users and debugging +systemdiff diff --technical before.json after.json -- **Offline-first:** core scanning, diffing, and reporting stay local. -- **No account:** local use does not require registration. -- **No telemetry in the MVP:** system data is not uploaded by default. -- **Read-only:** SystemDiff observes and reports; it does not clean or remediate a system. -- **Graceful privileges:** inaccessible scopes are reported as partial or permission denied rather than hidden. -- **Evidence-first:** JSON formats are versioned and deterministic, and the GUI will not hide raw evidence. -- **Privacy-aware:** real snapshots may contain sensitive data and must not be shared until reviewed or sanitized. +# Versioned, deterministic machine-readable document +systemdiff diff --json before.json after.json +``` -See [product principles](docs/product-principles.md) and the [threat model](docs/threat-model.md). +The default output uses no color or ANSI formatting, so meaning is preserved when redirected or piped. `--technical` exposes Collector version, scope, canonical identity, Registry hive/view/path, lossless value name, native type, decode status, values, SHA-256, and coverage diagnostics. `--json` preserves the language-neutral Diff schema. -## Current pre-v0.1 workflow +## Capture a real before/after pair ```powershell systemdiff snapshot -o before.json @@ -65,59 +68,53 @@ systemdiff snapshot -o after.json systemdiff diff before.json after.json ``` -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. +This workflow is currently limited to Registry Run/RunOnce evidence. Compare Snapshots from the same Windows installation and the same user/principal context. Snapshots and every Diff/report mode are unredacted: human text, technical text, and JSON can all contain sensitive command strings, usernames in paths, hashes, raw evidence, and other host details. Review every report before sharing, and never attach unreviewed real evidence to a public Issue. -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. +Current minimum collection platform: Windows 10 version 1709 or Windows Server 2016 version 1709. ARM64 captures current-user shared Registry scopes, but Collector v1 reports HKLM alternate-view coverage as unsupported until those views can be represented and tested correctly. -## MVP scope +## Why trust the design? -| Collector | v0.1 scope | Current status | -| --- | --- | --- | -| 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 | +- **Offline-first:** scanning, diffing, and reporting happen locally. +- **Read-only product behavior:** SystemDiff observes and reports; it does not clean, remediate, execute evidence, or change startup configuration. +- **Coverage is evidence:** permission and collection gaps are explicit. Incomplete scope coverage produces an Inconclusive result rather than a false Removed result. +- **Evidence remains inspectable:** plain-language output is layered over technical text and versioned JSON. +- **No account or telemetry:** the current product has no upload path, network client, or usage tracking. -Whole-drive hashing, automatic remediation, telemetry, cloud analysis, and a large desktop UI are out of scope for v0.1. +See the [product principles](docs/product-principles.md), [architecture](docs/architecture.md), [data format](docs/data-format.md), and [threat model](docs/threat-model.md). -## Developer quick start +## Build from source Prerequisites on Windows: - Git; - stable Rust MSVC toolchain with `rustfmt` and `clippy`; -- Microsoft C++ Build Tools (Desktop development with C++); -- WebView2 only when the future Tauri desktop app is introduced. +- Microsoft C++ Build Tools (Desktop development with C++). ```powershell cargo fmt --all --check cargo clippy --locked --workspace --all-targets -- -D warnings cargo test --locked --workspace --all-targets -# Exercise the current deterministic diff/report path with synthetic fixtures. -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 ``` -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. +There is no official binary release yet. The existing synthetic HKCU write-based E2E harness is test-only, requires two explicit gates, refuses to overwrite an existing value, performs exact-data guarded cleanup, and is not run by default CI. -## Architecture +## Architecture and roadmap -The Rust workspace keeps domain/schema, Windows access, deterministic diff, rules, reports, and CLI composition separate. The future Tauri desktop client will use the same Rust core. Tauri 2 + React + TypeScript is proposed, not yet accepted or generated. +The Rust workspace separates versioned domain data, Windows API access, deterministic Diff, rules, reporting, and CLI composition. The future desktop client is proposed to reuse the same core; no Tauri application has been generated. -Start with [architecture](docs/architecture.md), [data format](docs/data-format.md), [Collector notes](docs/collectors.md), and the [roadmap](docs/roadmap.md). +Registry startup is the first completed vertical slice, not the finished v0.1. See the [Collector notes](docs/collectors.md) and [roadmap](docs/roadmap.md) for current boundaries. ## Contributing Contributions are welcome in English or Chinese. Useful work is not limited to Rust: documentation, translations, synthetic fixtures, Windows API research, privacy analysis, issue reproduction, and UI design all matter. -Read [CONTRIBUTING.md](CONTRIBUTING.md) and [contributing a Collector](docs/contributing-collectors.md). Please never attach an unreviewed real snapshot or log to a public issue. +Read [CONTRIBUTING.md](CONTRIBUTING.md) and [contributing a Collector](docs/contributing-collectors.md). ## Security and project boundary -SystemDiff is defensive auditing software. Credential dumping, token/cookie extraction, keylogging, persistence creation, AV/EDR bypass, stealth/C2, exploitation, and unauthorized-access tooling are outside the project boundary. See [SECURITY.md](SECURITY.md). +SystemDiff is defensive auditing software. Credential dumping, token/cookie extraction, keylogging, persistence creation, AV/EDR bypass, stealth/C2, exploitation, and unauthorized-access tooling are outside the project boundary. Report vulnerabilities through [GitHub Private Vulnerability Reporting](https://github.com/XiaojuCH/SystemDiff/security/advisories/new); see [SECURITY.md](SECURITY.md). ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 18cd81e..90ad018 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,57 +2,62 @@ [English](README.md) | [简体中文](README.zh-CN.md) -**SystemDiff 帮你看清 Windows 系统发生了什么变化——每一条结论都附带可查证的证据。** +[![CI](https://github.com/XiaojuCH/SystemDiff/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/XiaojuCH/SystemDiff/actions/workflows/ci.yml) + +## 看看应用、安装程序和脚本到底在 Windows 上改了什么。 + +**离线优先 · 只读 · 无需账号 · 不含遥测** + +SystemDiff 会分别创建变更前后的 Snapshot,再说明两者之间有哪些证据发生了变化。它要回答的是这类问题:“我刚安装了这个程序,它往启动项里加了什么?” > [!IMPORTANT] -> SystemDiff 仍是没有面向普通用户发行包的预发布软件。开发版 CLI 现在已经能通过第一个真实、只读的 Collector 采集 Windows 官方文档中的 Run/RunOnce 启动项,并比较两份 Snapshot。Windows 服务、计划任务、解释规则、脱敏和桌面应用均尚未实现。 +> SystemDiff 仍处于预发布阶段,目前只能从源码构建。现阶段真正支持的是采集并比较 Windows Registry 中有官方文档的 Run/RunOnce 启动项。Windows 服务、计划任务、规则、脱敏、正式发布包和桌面应用均尚未实现。 -## 为什么要做 SystemDiff? +[试用示例](#试用-registry-示例) · [从源码构建](#从源码构建) · [查看数据格式](docs/data-format.md) -核心流程很简单: +![SystemDiff 显示新增的一条 synthetic Registry 启动项](docs/assets/registry-startup-demo.svg) -1. 创建快照(Snapshot)A。 -2. 安装、运行或改动一些东西。 -3. 创建快照 B。 -4. 对比两份 Snapshot。 -5. 看清楚到底变了什么。 +_图中是仓库内 Registry-only synthetic fixtures 生成并经过验证的真实输出,不含真实主机数据。_ -普通用户看到的应该是克制、好懂的说明: +## 当前可用能力 -```text -需要较高关注 +| 能力 | 状态 | +| --- | --- | +| 采集当前用户和本机范围内的 Run/RunOnce 证据 | 已在受支持的 Windows 系统上实现 | +| 易读文本、technical 文本和确定性 JSON 三种 Diff 输出 | 已实现 | +| 感知采集覆盖情况,不把缺失证据误报为删除 | 已实现 | +| Windows Services Collector | 计划作为下一个 Collector;尚未实现 | +| Scheduled Tasks Collector | 计划中;尚未实现 | +| 规则、数字签名、风险判断和脱敏分享 | 计划中;尚未实现 | -ExampleUpdater - 将自身加入了启动项 - 会在 Windows 启动时自动运行 - 文件没有数字签名 - 位置:AppData - 打开技术细节 +SystemDiff 目前只陈述“已加入当前用户启动项”这类事实,不会判断某个条目是否恶意、安全、已签名或应该删除。 -正常 +## 试用 Registry 示例 -ExampleApp 设置 - 添加了应用配置 - 通常无害 +安装 stable Rust MSVC toolchain 后运行: + +```powershell +cargo run --locked --quiet -p systemdiff-cli -- diff fixtures/snapshots/registry-before-v1.json fixtures/snapshots/registry-after-v1.json ``` -以上只是效果示意,不代表目前已有对应的检测能力。当前的注册表 Diff 已可显示精确路径、value name/type、类型化解码状态、完整 value 的 SHA-256、Collector/scope identity 和结构化 JSON;服务/任务证据、规则和签名元数据仍在计划中。 +示例只包含一条 synthetic `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` 新增项。它与上方图片中的输出完全一致,并由回归测试固定。 -SystemDiff 不会把“不常见”直接等同于“恶意”。解释始终建立在证据之上,而不是取代证据。 +三种输出模式分别面向不同需求: -## 信任模型 +```powershell +# 默认:平静、易读的摘要 +systemdiff diff before.json after.json -- **离线优先:** 核心扫描、Diff 和报告全部在本地运行。 -- **无需账号:** 本地使用无需注册。 -- **MVP 不含遥测:** 默认不会上传系统数据。 -- **只读:** SystemDiff 只观察、只报告,不会自动清理或修复系统。 -- **明确报告权限限制:** 无法访问的部分会明确标记为 `partial` 或 `permission denied`,不会悄悄忽略。 -- **证据优先:** JSON 格式带有版本号且输出可复现;后续 GUI 不会隐藏原始数据。 -- **注意隐私:** 真实 Snapshot 可能包含敏感信息,未经审查或脱敏处理前不得分享。 +# 面向专业用户和调试的完整文本证据 +systemdiff diff --technical before.json after.json -详见[产品原则](docs/product-principles.md)和[威胁模型](docs/threat-model.md)。 +# 版本化、确定性的机器可读文档 +systemdiff diff --json before.json after.json +``` -## 当前 pre-v0.1 流程 +默认输出不使用颜色或 ANSI 格式,因此重定向到文件或管道后仍能看懂。`--technical` 会显示 Collector version、scope、canonical identity、Registry hive/view/path、无损 value name、原生类型、decode status、before/after value、SHA-256 和 coverage diagnostics。`--json` 保持语言无关的 Diff schema。 + +## 采集真实的 before/after Snapshot ```powershell systemdiff snapshot -o before.json @@ -63,60 +68,54 @@ systemdiff snapshot -o after.json systemdiff diff before.json after.json ``` -从源代码构建后,这条链路目前可在受支持的 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`。 +当前流程只覆盖 Registry Run/RunOnce 证据。两份 Snapshot 必须来自同一套 Windows 安装、同一用户/主体上下文。Snapshot 和所有 Diff/report 模式均未脱敏:易读文本、technical 文本和 JSON 都可能包含命令字符串、路径中的用户名、hash、raw evidence 和其他主机信息。分享前务必检查每一份报告,绝不要把未经检查的真实证据附到公开 Issue 中。 -v0.1 的 Diff 只用于比较同一套 Windows 系统、同一用户/主体上下文中的 before/after Snapshot;跨主机或跨用户身份关联不在当前范围内。 +当前最低支持 Windows 10 version 1709 或 Windows Server 2016 version 1709。ARM64 可以采集当前用户的 Shared Registry scopes;在能够正确表达并测试相关 view semantics 之前,Collector v1 会明确把 HKLM alternate-view coverage 标记为 unsupported。 -## MVP 范围 +## 为什么可以信任这套设计? -| Collector | v0.1 范围 | 当前状态 | -| --- | --- | --- | -| 注册表启动项 | 官方文档列出的 Run/RunOnce 位置和明确的 Registry view | 已在开发版 CLI 中实现 | -| Windows 服务 | 稳定的 Win32 服务配置;不含驱动 | 计划中 | -| 计划任务 | Task Scheduler 2.0 配置,并明确显示因权限不足造成的覆盖缺口 | 计划中 | +- **离线优先:** 扫描、Diff 和报告都在本地完成。 +- **产品行为只读:** SystemDiff 只观察和报告,不会清理、修复、执行证据或修改启动配置。 +- **覆盖情况也是证据:** 权限或采集缺口会明确报告。scope 不完整时,结果是 Inconclusive,而不是误报为 Removed。 +- **证据始终可查:** 易读说明之下保留 technical 文本和带版本的 JSON。 +- **无需账号,不含遥测:** 当前产品没有上传路径、网络客户端或使用情况跟踪。 -全盘哈希、自动修复、遥测、云端分析和大型桌面 GUI 均不在 v0.1 范围内。 +进一步了解可阅读[产品原则](docs/product-principles.md)、[架构](docs/architecture.md)、[数据格式](docs/data-format.md)和[威胁模型](docs/threat-model.md)。 -## 开发者快速上手 +## 从源码构建 -Windows 前置条件: +Windows 环境需要: -- Git -- 安装了 `rustfmt` 和 `clippy` 的 stable Rust MSVC toolchain -- Microsoft C++ Build Tools(“使用 C++ 的桌面开发”工作负载) -- WebView2(仅在未来引入 Tauri 桌面应用时需要) +- Git; +- 带 `rustfmt` 和 `clippy` 的 stable Rust MSVC toolchain; +- Microsoft C++ Build Tools(“使用 C++ 的桌面开发”工作负载)。 ```powershell cargo fmt --all --check cargo clippy --locked --workspace --all-targets -- -D warnings cargo test --locked --workspace --all-targets -# 使用 synthetic fixture 运行当前已有的确定性 Diff / 报告链路。 -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 ``` -本 workspace 和 opt-in synthetic HKCU Registry E2E 已在真实的 stable Rust MSVC toolchain 下验证。E2E harness 只用于测试,需要两项显式 gate,会拒绝覆盖现有 value,默认 CI 不会运行。确切的验证状态和剩余产品限制见 [.agent/PROJECT_STATE.md](.agent/PROJECT_STATE.md)。 +目前还没有官方二进制 Release。现有的 synthetic HKCU 写入型 E2E harness 只用于测试,需要两个显式 gate,会拒绝覆盖已有 value,并使用 exact-data guarded cleanup;默认 CI 不会运行它。 -## 架构 +## 架构与路线图 -Rust workspace 将领域模型/schema、Windows 系统访问、确定性 Diff、规则、报告生成和 CLI 组装各自分离。未来的 Tauri 桌面客户端将复用同一个 Rust core。Tauri 2 + React + TypeScript 目前仅为提议方案,尚未正式采纳或生成代码。 +Rust workspace 把带版本的领域数据、Windows API 访问、确定性 Diff、规则、报告和 CLI 组合彼此分离。未来的桌面客户端计划复用同一套 core;目前尚未生成 Tauri 应用。 -建议从以下文档入手:[架构](docs/architecture.md)、[数据格式](docs/data-format.md)、[Collector 说明](docs/collectors.md)和[路线图](docs/roadmap.md)。 +Registry startup 是第一个完成的 vertical slice,并不代表 v0.1 已经完成。当前边界和后续计划见 [Collector 说明](docs/collectors.md)与[路线图](docs/roadmap.md)。 ## 参与贡献 -欢迎使用中文或英文参与贡献。有价值的贡献不限于 Rust 代码:文档、翻译、synthetic fixture、Windows API 调研、隐私分析、问题复现和 UI 设计都很重要。 +欢迎使用中文或英文参与贡献。贡献不只限于 Rust 代码:文档、本地化、synthetic fixtures、Windows API 调研、隐私分析、问题复现和 UI 设计都很有价值。 -请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和 [Collector 贡献指南](docs/contributing-collectors.md)。请勿在公开 Issue 中附带未经审查的真实 Snapshot 或日志。 +请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和 [Collector 贡献指南](docs/contributing-collectors.md)。 ## 安全与项目边界 -SystemDiff 是面向防御的审计工具。凭据转储、token/cookie 提取、键盘记录、创建持久化、绕过 AV/EDR、stealth/evasion 工具、RAT/C2 功能、自动化漏洞利用和未授权访问工具均不属于本项目范围。详见 [SECURITY.md](SECURITY.md)。 +SystemDiff 是防御性审计软件。凭据转储、token/cookie 提取、键盘记录、创建持久化、绕过 AV/EDR、stealth/C2、漏洞利用和未授权访问工具均不属于项目范围。请通过 [GitHub Private Vulnerability Reporting](https://github.com/XiaojuCH/SystemDiff/security/advisories/new) 报告安全漏洞,详情见 [SECURITY.md](SECURITY.md)。 ## 许可证 -本项目基于 [Apache License 2.0](LICENSE) 授权。 +本项目采用 [Apache License 2.0](LICENSE) 授权。 diff --git a/crates/systemdiff-cli/src/main.rs b/crates/systemdiff-cli/src/main.rs index 84d3dac..33a53af 100644 --- a/crates/systemdiff-cli/src/main.rs +++ b/crates/systemdiff-cli/src/main.rs @@ -9,7 +9,7 @@ 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_report::{write_json, write_technical, write_terminal}; use systemdiff_windows::{capture_snapshot, mvp_collector_plans}; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; @@ -41,9 +41,13 @@ enum Command { /// Compare two draft SystemDiff snapshot files. Diff { /// Render the versioned JSON diff instead of terminal text. - #[arg(long)] + #[arg(long, conflicts_with = "technical")] json: bool, + /// Render exact technical evidence as plain text. + #[arg(long, conflicts_with = "json")] + technical: bool, + /// Include observations that did not change. #[arg(long)] include_unchanged: bool, @@ -85,6 +89,7 @@ fn run(cli: Cli) -> Result<(), Box> { } Command::Diff { json, + technical, include_unchanged, before, after, @@ -97,6 +102,8 @@ fn run(cli: Cli) -> Result<(), Box> { let mut output = stdout.lock(); if json { write_json(&mut output, &diff)?; + } else if technical { + write_technical(&mut output, &diff, &before, &after)?; } else { write_terminal(&mut output, &diff)?; } @@ -298,12 +305,49 @@ mod tests { cli.command, Command::Diff { json: true, + technical: false, + include_unchanged: false, + .. + } + )); + } + + #[test] + fn parses_technical_diff_command() { + let cli = Cli::try_parse_from([ + "systemdiff", + "diff", + "--technical", + "before.json", + "after.json", + ]) + .expect("technical diff command must parse"); + + assert!(matches!( + cli.command, + Command::Diff { + json: false, + technical: true, include_unchanged: false, .. } )); } + #[test] + fn technical_and_json_modes_are_mutually_exclusive() { + let result = Cli::try_parse_from([ + "systemdiff", + "diff", + "--technical", + "--json", + "before.json", + "after.json", + ]); + + assert!(result.is_err()); + } + #[test] fn parses_snapshot_output_command() { let cli = Cli::try_parse_from(["systemdiff", "snapshot", "-o", "snapshot.json"]) diff --git a/crates/systemdiff-report/Cargo.toml b/crates/systemdiff-report/Cargo.toml index 2776c7b..7cece9a 100644 --- a/crates/systemdiff-report/Cargo.toml +++ b/crates/systemdiff-report/Cargo.toml @@ -9,4 +9,5 @@ publish = false [dependencies] serde.workspace = true serde_json.workspace = true +systemdiff-core.workspace = true systemdiff-diff.workspace = true diff --git a/crates/systemdiff-report/src/lib.rs b/crates/systemdiff-report/src/lib.rs index 72e0b2e..3472592 100644 --- a/crates/systemdiff-report/src/lib.rs +++ b/crates/systemdiff-report/src/lib.rs @@ -1,67 +1,934 @@ #![forbid(unsafe_code)] use serde::Serialize; -use std::collections::BTreeMap; use std::error::Error; use std::fmt; use std::fmt::Write as FmtWrite; use std::io::{self, Write as IoWrite}; -use systemdiff_diff::DiffDocument; +use systemdiff_core::{ + Artifact, CollectorStatus, Diagnostic, RegistryDecodedValue, RegistryHive, RegistryRawEvidence, + RegistryStartupEntry, RegistryStartupKind, RegistryValueDecoding, RegistryValueName, + RegistryView, RunOncePrefixSemantics, Snapshot, +}; +use systemdiff_diff::{ArtifactChange, ChangeKind, DiffDocument, DiffWarning}; pub fn write_json(mut writer: W, value: &T) -> Result<(), ReportError> { serde_json::to_writer_pretty(&mut writer, value).map_err(ReportError::Json)?; writer.write_all(b"\n").map_err(ReportError::Io) } +/// Renders the calm, evidence-backed default terminal view. +/// +/// Snapshot strings are untrusted evidence. Control characters are escaped so +/// they cannot inject terminal sequences or additional report lines. pub fn render_terminal(diff: &DiffDocument) -> String { let mut output = String::new(); - let _ = writeln!(output, "SystemDiff diff"); - let _ = writeln!(output, "Before: {}", diff.before_captured_at); - let _ = writeln!(output, "After: {}", diff.after_captured_at); - - let counts = diff + let confirmed = diff .changes .iter() - .fold(BTreeMap::new(), |mut counts, item| { - *counts.entry(item.change.label()).or_insert(0_usize) += 1; - counts - }); - if counts.is_empty() { - let _ = writeln!(output, "Changes: none"); + .filter(|item| { + matches!( + item.change, + ChangeKind::Added { .. } | ChangeKind::Removed { .. } | ChangeKind::Modified { .. } + ) + }) + .count(); + let inconclusive = diff + .changes + .iter() + .filter(|item| matches!(item.change, ChangeKind::Inconclusive { .. })) + .count(); + let unchanged = diff + .changes + .iter() + .filter(|item| matches!(item.change, ChangeKind::Unchanged { .. })) + .count(); + + if diff.changes.is_empty() && diff.warnings.is_empty() { + let _ = writeln!(output, "No changes found"); } else { - let summary = counts - .iter() - .map(|(label, count)| format!("{label}={count}")) - .collect::>() - .join(", "); - let _ = writeln!(output, "Changes: {summary}"); + if confirmed == 0 { + let _ = writeln!(output, "No confirmed changes"); + } else { + let _ = writeln!( + output, + "{} confirmed {}", + confirmed, + plural(confirmed, "change", "changes") + ); + } + if inconclusive > 0 { + let _ = writeln!( + output, + "Could not confirm {} possible {}", + inconclusive, + plural(inconclusive, "change", "changes") + ); + } + if unchanged > 0 { + let _ = writeln!( + output, + "{} unchanged {} shown", + unchanged, + plural(unchanged, "entry", "entries") + ); + } + } + let _ = writeln!( + output, + "Compared {} -> {}", + terminal_text(&diff.before_captured_at), + terminal_text(&diff.after_captured_at) + ); + + let registry_changes: Vec<_> = diff + .changes + .iter() + .filter(|item| change_artifact(item).is_some_and(is_registry)) + .collect(); + if !registry_changes.is_empty() { + let _ = writeln!(output, "\nRegistry startup changes\n"); + for (index, item) in registry_changes.into_iter().enumerate() { + if index > 0 { + let _ = writeln!(output); + } + render_human_registry_change(&mut output, item); + } + } + + let other_changes: Vec<_> = diff + .changes + .iter() + .filter(|item| !change_artifact(item).is_some_and(is_registry)) + .collect(); + if !other_changes.is_empty() { + let _ = writeln!(output, "\nOther evidence changes\n"); + for item in other_changes { + render_human_fallback(&mut output, item); + } + } + + if !diff.warnings.is_empty() { + let _ = writeln!( + output, + "\nCoverage notes\n\n{} {} could not be fully checked", + diff.warnings.len(), + plural(diff.warnings.len(), "scope", "scopes") + ); + for warning in &diff.warnings { + render_human_warning(&mut output, warning); + } + } + + output +} + +pub fn write_terminal(mut writer: W, diff: &DiffDocument) -> io::Result<()> { + writer.write_all(render_terminal(diff).as_bytes()) +} + +/// Renders exact evidence for power users and debugging without changing the +/// versioned JSON Diff document. +pub fn render_technical(diff: &DiffDocument, before: &Snapshot, after: &Snapshot) -> String { + let mut output = String::new(); + let _ = writeln!(output, "SystemDiff technical diff"); + let _ = writeln!( + output, + "Before: {}\nAfter: {}", + terminal_text(&diff.before_captured_at), + terminal_text(&diff.after_captured_at) + ); + let mut counts = [0_usize; 5]; + for item in &diff.changes { + counts[change_index(&item.change)] += 1; } + let _ = writeln!( + output, + "Changes: added={}, modified={}, removed={}, inconclusive={}, unchanged={}", + counts[0], counts[1], counts[2], counts[3], counts[4] + ); for item in &diff.changes { let _ = writeln!( output, - "{} {}", - item.change.label().to_uppercase(), - item.key + "\n{} {}", + item.change.label().to_ascii_uppercase(), + terminal_text(&item.change_id) ); + let _ = writeln!( + output, + " collector ID: {}", + terminal_text(&item.key.collector_id) + ); + render_versions(&mut output, &item.key.collector_id, before, after); + let _ = writeln!(output, " scope: {}", terminal_text(&item.key.scope_id)); + let _ = writeln!( + output, + " artifact kind: {}", + terminal_text(&item.key.artifact_kind) + ); + let _ = writeln!( + output, + " canonical identity: {}", + terminal_text(&item.key.canonical_id) + ); + + match &item.change { + ChangeKind::Added { after } => render_technical_artifact(&mut output, "After", after), + ChangeKind::Removed { before } => { + render_technical_artifact(&mut output, "Before", before); + } + ChangeKind::Modified { before, after } => { + render_technical_artifact(&mut output, "Before", before); + render_technical_artifact(&mut output, "After", after); + } + ChangeKind::Unchanged { artifact } => { + render_technical_artifact(&mut output, "Evidence", artifact); + } + ChangeKind::Inconclusive { + before, + after, + reason, + } => { + let _ = writeln!(output, " reason: {}", inconclusive_reason(*reason)); + if let Some(before) = before { + render_technical_artifact(&mut output, "Before", before); + } else { + let _ = writeln!(output, " Before: no observation"); + } + if let Some(after) = after { + render_technical_artifact(&mut output, "After", after); + } else { + let _ = writeln!(output, " After: no observation"); + } + } + } } - if !diff.warnings.is_empty() { - let _ = writeln!(output, "Warnings:"); + let _ = writeln!(output, "\nCoverage"); + render_snapshot_coverage(&mut output, "Before Snapshot", before); + render_snapshot_coverage(&mut output, "After Snapshot", after); + let _ = writeln!(output, "Diff coverage warnings:"); + if diff.warnings.is_empty() { + let _ = writeln!(output, " none"); + } else { for warning in &diff.warnings { let _ = writeln!( output, - " coverage incomplete for {}/{} (before={:?}, after={:?})", - warning.collector_id, warning.scope_id, warning.before_status, warning.after_status + " {}/{}: before={}, after={}", + terminal_text(&warning.collector_id), + terminal_text(&warning.scope_id), + optional_status(warning.before_status), + optional_status(warning.after_status) ); } } + render_snapshot_diagnostics(&mut output, "Before diagnostics", before); + render_snapshot_diagnostics(&mut output, "After diagnostics", after); output } -pub fn write_terminal(mut writer: W, diff: &DiffDocument) -> io::Result<()> { - writer.write_all(render_terminal(diff).as_bytes()) +fn render_snapshot_coverage(output: &mut String, heading: &str, snapshot: &Snapshot) { + let mut collectors: Vec<_> = snapshot.collectors.iter().collect(); + collectors.sort_by(|left, right| left.id.cmp(&right.id)); + let _ = writeln!(output, "{heading} collector coverage:"); + if collectors.is_empty() { + let _ = writeln!(output, " none"); + return; + } + for collector in collectors { + let _ = writeln!( + output, + " collector ID: {}\n version: {}\n aggregate status: {}", + terminal_text(&collector.id), + collector.version, + status(collector.status) + ); + let mut scopes: Vec<_> = collector.coverage.iter().collect(); + scopes.sort_by(|left, right| left.scope_id.cmp(&right.scope_id)); + for scope in scopes { + let _ = writeln!( + output, + " scope {}: {}", + terminal_text(&scope.scope_id), + status(scope.status) + ); + } + } +} + +pub fn write_technical( + mut writer: W, + diff: &DiffDocument, + before: &Snapshot, + after: &Snapshot, +) -> io::Result<()> { + writer.write_all(render_technical(diff, before, after).as_bytes()) +} + +fn render_human_registry_change(output: &mut String, item: &ArtifactChange) { + match &item.change { + ChangeKind::Added { + after: Artifact::RegistryStartup(entry), + } => { + human_entry_heading(output, '+', "Added", entry); + let _ = writeln!( + output, + " Added to {} {}", + human_hive(entry.hive), + human_startup_location(entry.startup_kind) + ); + render_human_value(output, "Value", entry); + render_human_location(output, entry); + } + ChangeKind::Removed { + before: Artifact::RegistryStartup(entry), + } => { + human_entry_heading(output, '-', "Removed", entry); + let _ = writeln!( + output, + " Removed from {} {}", + human_hive(entry.hive), + human_startup_location(entry.startup_kind) + ); + render_human_value(output, "Previous value", entry); + render_human_location(output, entry); + } + ChangeKind::Modified { + before: Artifact::RegistryStartup(before), + after: Artifact::RegistryStartup(after), + } => { + human_entry_heading(output, '~', "Modified", after); + if matches!( + (decoded_command(before), decoded_command(after)), + (Some(before), Some(after)) if before != after + ) { + let _ = writeln!(output, " Startup command changed"); + } else { + let _ = writeln!(output, " Registry startup evidence changed"); + } + render_human_value(output, "Before", before); + render_human_value(output, "After", after); + render_human_location(output, after); + } + ChangeKind::Unchanged { + artifact: Artifact::RegistryStartup(entry), + } => { + human_entry_heading(output, '=', "Unchanged", entry); + let _ = writeln!(output, " No change in the captured Registry evidence"); + render_human_value(output, "Value", entry); + render_human_location(output, entry); + } + ChangeKind::Inconclusive { before, after, .. } => { + let entry = before.as_ref().or(after.as_ref()).and_then(registry_entry); + if let Some(entry) = entry { + human_entry_heading(output, '?', "Inconclusive", entry); + let _ = writeln!( + output, + " The corresponding Registry scope had incomplete coverage, so this change could not be confirmed." + ); + render_human_value(output, "Observed value", entry); + render_human_location(output, entry); + } + } + _ => render_human_fallback(output, item), + } +} + +fn human_entry_heading( + output: &mut String, + symbol: char, + change: &str, + entry: &RegistryStartupEntry, +) { + let _ = writeln!(output, " {symbol} {}", human_value_name(&entry.value_name)); + let _ = writeln!( + output, + " {change} ({})", + startup_kind(entry.startup_kind) + ); +} + +fn render_human_value(output: &mut String, heading: &str, entry: &RegistryStartupEntry) { + match &entry.decoding { + RegistryValueDecoding::Decoded { value } => { + let label = if matches!( + value, + RegistryDecodedValue::String { .. } | RegistryDecodedValue::ExpandString { .. } + ) { + if heading == "Value" { + "Command" + } else { + heading + } + } else { + heading + }; + let _ = writeln!(output, "\n {label}\n {}", human_decoded(value)); + } + RegistryValueDecoding::InvalidData => { + let _ = writeln!( + output, + "\n Registry value data was not decoded because it is invalid for its native type." + ); + } + RegistryValueDecoding::UnsupportedType => { + let _ = writeln!( + output, + "\n Registry value data was not decoded because its native type is not supported." + ); + } + RegistryValueDecoding::NotApplicable => { + let _ = writeln!( + output, + "\n Registry value data was not decoded because this native type has no typed representation." + ); + } + } +} + +fn render_human_location(output: &mut String, entry: &RegistryStartupEntry) { + let _ = writeln!( + output, + "\n Location\n {}\\{}{}", + hive_abbreviation(entry.hive), + terminal_text(&entry.key_path), + human_view_suffix(entry.registry_view) + ); +} + +fn render_human_fallback(output: &mut String, item: &ArtifactChange) { + let artifact = change_artifact(item); + let name = artifact.map(human_artifact_name).unwrap_or_else(|| { + format!( + "{} evidence", + terminal_text(&item.key.artifact_kind).replace('_', " ") + ) + }); + let symbol = match item.change { + ChangeKind::Added { .. } => '+', + ChangeKind::Removed { .. } => '-', + ChangeKind::Modified { .. } => '~', + ChangeKind::Unchanged { .. } => '=', + ChangeKind::Inconclusive { .. } => '?', + }; + let _ = writeln!( + output, + " {symbol} {name}\n {}", + human_change_label(&item.change) + ); +} + +fn render_human_warning(output: &mut String, warning: &DiffWarning) { + let _ = writeln!( + output, + " ! {}\n Before: {}\n After: {}", + human_scope_label(&warning.collector_id, &warning.scope_id), + optional_status(warning.before_status), + optional_status(warning.after_status) + ); +} + +fn render_versions(output: &mut String, collector_id: &str, before: &Snapshot, after: &Snapshot) { + let before_version = collector_version(before, collector_id); + let after_version = collector_version(after, collector_id); + if before_version == after_version { + if let Some(version) = before_version { + let _ = writeln!(output, " version: {version}"); + } else { + let _ = writeln!(output, " version: unavailable in both Snapshots"); + } + } else { + let _ = writeln!( + output, + " before version: {}", + optional_version(before_version) + ); + let _ = writeln!( + output, + " after version: {}", + optional_version(after_version) + ); + } +} + +fn render_technical_artifact(output: &mut String, heading: &str, artifact: &Artifact) { + let _ = writeln!(output, " {heading} evidence:"); + match artifact { + Artifact::RegistryStartup(entry) => render_technical_registry(output, entry), + Artifact::WindowsService(service) => { + let _ = writeln!( + output, + " service name: {}\n display name: {}\n binary path: {}", + terminal_text(&service.service_name), + service + .display_name + .as_deref() + .map(terminal_text) + .unwrap_or_else(|| "none".to_owned()), + terminal_text(&service.binary_path) + ); + } + Artifact::ScheduledTask(task) => { + let _ = writeln!( + output, + " task path: {}\n enabled: {}\n hidden: {}\n action count: {}", + terminal_text(&task.task_path), + task.enabled, + task.hidden, + task.actions.len() + ); + } + } +} + +fn render_technical_registry(output: &mut String, entry: &RegistryStartupEntry) { + let _ = writeln!( + output, + " Registry hive: {}\n Registry view: {}\n Registry path: {}\\{}\n startup kind: {}", + technical_hive(entry.hive), + technical_view(entry.registry_view), + hive_abbreviation(entry.hive), + terminal_text(&entry.key_path), + technical_startup_kind(entry.startup_kind) + ); + match &entry.value_name { + RegistryValueName::Decoded { value } => { + let _ = writeln!( + output, + " value name encoding: decoded\n value name: {}", + if value.is_empty() { + "".to_owned() + } else { + terminal_text(value) + } + ); + } + RegistryValueName::InvalidUtf16 { utf16le_hex } => { + let _ = writeln!( + output, + " value name encoding: invalid_utf16\n value name UTF-16LE hex: {}", + terminal_text(utf16le_hex) + ); + } + } + let _ = writeln!( + output, + " RunOnce prefix: {}\n value type: {} ({})\n decode status: {}", + entry + .run_once_prefix + .map(technical_prefix) + .unwrap_or("not_applicable"), + entry.value_type, + registry_type_name(entry.value_type), + decode_status(&entry.decoding) + ); + if let RegistryValueDecoding::Decoded { value } = &entry.decoding { + let _ = writeln!(output, " decoded value: {}", technical_decoded(value)); + } + let _ = writeln!(output, " SHA-256: {}", entry.content_sha256); + match &entry.raw_evidence { + Some(raw) => render_raw_evidence(output, raw), + None => { + let _ = writeln!(output, " raw evidence: none"); + } + } +} + +fn render_raw_evidence(output: &mut String, raw: &RegistryRawEvidence) { + let _ = writeln!( + output, + " raw evidence: {}\n captured bytes: {}\n original bytes: {}\n truncated: {}", + terminal_text(&raw.content_hex), + raw.captured_byte_count, + raw.original_byte_count, + raw.truncated + ); +} + +fn render_snapshot_diagnostics(output: &mut String, heading: &str, snapshot: &Snapshot) { + let mut diagnostics: Vec<(&str, &Diagnostic)> = snapshot + .collectors + .iter() + .flat_map(|run| { + run.diagnostics + .iter() + .map(move |diagnostic| (run.id.as_str(), diagnostic)) + }) + .collect(); + diagnostics.sort_by(|left, right| { + ( + left.0, + &left.1.scope_id, + &left.1.code, + &left.1.stage, + left.1.native_code, + &left.1.message, + ) + .cmp(&( + right.0, + &right.1.scope_id, + &right.1.code, + &right.1.stage, + right.1.native_code, + &right.1.message, + )) + }); + let _ = writeln!(output, "{heading}:"); + if diagnostics.is_empty() { + let _ = writeln!(output, " none"); + return; + } + for (collector_id, diagnostic) in diagnostics { + let _ = writeln!( + output, + " collector ID: {}\n scope: {}\n code: {}\n message: {}\n stage: {}\n native code: {}", + terminal_text(collector_id), + diagnostic + .scope_id + .as_deref() + .map(terminal_text) + .unwrap_or_else(|| "collector-wide".to_owned()), + terminal_text(&diagnostic.code), + terminal_text(&diagnostic.message), + diagnostic + .stage + .as_deref() + .map(terminal_text) + .unwrap_or_else(|| "none".to_owned()), + diagnostic + .native_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_owned()) + ); + } +} + +fn change_artifact(change: &ArtifactChange) -> Option<&Artifact> { + match &change.change { + ChangeKind::Added { after } => Some(after), + ChangeKind::Removed { before } => Some(before), + ChangeKind::Modified { after, .. } => Some(after), + ChangeKind::Unchanged { artifact } => Some(artifact), + ChangeKind::Inconclusive { before, after, .. } => before.as_ref().or(after.as_ref()), + } +} + +fn registry_entry(artifact: &Artifact) -> Option<&RegistryStartupEntry> { + match artifact { + Artifact::RegistryStartup(entry) => Some(entry), + _ => None, + } +} + +fn is_registry(artifact: &Artifact) -> bool { + matches!(artifact, Artifact::RegistryStartup(_)) +} + +fn human_artifact_name(artifact: &Artifact) -> String { + match artifact { + Artifact::RegistryStartup(entry) => human_value_name(&entry.value_name), + Artifact::WindowsService(service) => service + .display_name + .as_deref() + .filter(|value| !value.is_empty()) + .map(terminal_text) + .unwrap_or_else(|| terminal_text(&service.service_name)), + Artifact::ScheduledTask(task) => terminal_text(&task.task_path), + } +} + +fn human_value_name(name: &RegistryValueName) -> String { + match name { + RegistryValueName::Decoded { value } if value.is_empty() => { + "Default value (unnamed)".to_owned() + } + RegistryValueName::Decoded { value } => terminal_text(value), + RegistryValueName::InvalidUtf16 { .. } => "Name could not be decoded as UTF-16".to_owned(), + } +} + +fn decoded_command(entry: &RegistryStartupEntry) -> Option<&str> { + match &entry.decoding { + RegistryValueDecoding::Decoded { + value: + RegistryDecodedValue::String { value } | RegistryDecodedValue::ExpandString { value }, + } => Some(value), + _ => None, + } +} + +fn human_decoded(value: &RegistryDecodedValue) -> String { + match value { + RegistryDecodedValue::String { value } | RegistryDecodedValue::ExpandString { value } => { + terminal_text(value) + } + RegistryDecodedValue::MultiString { values } => values + .iter() + .map(|value| terminal_text(value)) + .collect::>() + .join(" | "), + RegistryDecodedValue::Dword { value } => value.to_string(), + RegistryDecodedValue::Qword { value } => value.to_string(), + } +} + +fn technical_decoded(value: &RegistryDecodedValue) -> String { + match value { + RegistryDecodedValue::String { value } => format!("string: {}", technical_literal(value)), + RegistryDecodedValue::ExpandString { value } => { + format!("expand_string (unexpanded): {}", technical_literal(value)) + } + RegistryDecodedValue::MultiString { values } => format!( + "multi_string ({} elements): {}", + values.len(), + values + .iter() + .enumerate() + .map(|(index, value)| format!("[{index}]={}", technical_literal(value))) + .collect::>() + .join("; ") + ), + RegistryDecodedValue::Dword { value } => format!("dword: {value}"), + RegistryDecodedValue::Qword { value } => format!("qword: {value}"), + } +} + +fn technical_literal(value: &str) -> String { + let mut escaped = String::with_capacity(value.len().saturating_add(2)); + escaped.push('"'); + for character in value.chars() { + match character { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() || is_unsafe_format_character(character) => { + let _ = write!(escaped, "\\u{{{:x}}}", u32::from(character)); + } + character => escaped.push(character), + } + } + escaped.push('"'); + escaped +} + +fn terminal_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() || is_unsafe_format_character(character) => { + let _ = write!(escaped, "\\u{{{:x}}}", u32::from(character)); + } + character => escaped.push(character), + } + } + escaped +} + +fn is_unsafe_format_character(character: char) -> bool { + matches!( + character, + '\u{061c}' + | '\u{200b}'..='\u{200f}' + | '\u{2028}'..='\u{2029}' + | '\u{202a}'..='\u{202e}' + | '\u{2060}'..='\u{206f}' + | '\u{feff}' + ) +} + +fn collector_version(snapshot: &Snapshot, collector_id: &str) -> Option { + snapshot + .collectors + .iter() + .find(|run| run.id == collector_id) + .map(|run| run.version) +} + +fn optional_version(version: Option) -> String { + version + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_owned()) +} + +fn human_change_label(change: &ChangeKind) -> &'static str { + match change { + ChangeKind::Added { .. } => "Added", + ChangeKind::Removed { .. } => "Removed", + ChangeKind::Modified { .. } => "Modified", + ChangeKind::Unchanged { .. } => "Unchanged", + ChangeKind::Inconclusive { .. } => "Inconclusive because coverage was incomplete", + } +} + +fn human_hive(hive: RegistryHive) -> &'static str { + match hive { + RegistryHive::CurrentUser => "current-user", + RegistryHive::LocalMachine => "machine-wide", + } +} + +fn hive_abbreviation(hive: RegistryHive) -> &'static str { + match hive { + RegistryHive::CurrentUser => "HKCU", + RegistryHive::LocalMachine => "HKLM", + } +} + +fn technical_hive(hive: RegistryHive) -> &'static str { + match hive { + RegistryHive::CurrentUser => "current_user", + RegistryHive::LocalMachine => "local_machine", + } +} + +fn startup_kind(kind: RegistryStartupKind) -> &'static str { + match kind { + RegistryStartupKind::Run => "Run", + RegistryStartupKind::RunOnce => "RunOnce", + } +} + +fn technical_startup_kind(kind: RegistryStartupKind) -> &'static str { + match kind { + RegistryStartupKind::Run => "run", + RegistryStartupKind::RunOnce => "run_once", + } +} + +fn human_startup_location(kind: RegistryStartupKind) -> &'static str { + match kind { + RegistryStartupKind::Run => "startup", + RegistryStartupKind::RunOnce => "one-time startup (RunOnce)", + } +} + +fn human_view_suffix(view: RegistryView) -> &'static str { + match view { + RegistryView::Shared | RegistryView::Native => "", + RegistryView::Registry32 => " (32-bit Registry view)", + RegistryView::Registry64 => " (64-bit Registry view)", + } +} + +fn human_scope_label(collector_id: &str, scope_id: &str) -> String { + if collector_id != "windows.registry.startup" { + return format!( + "{}/{}", + terminal_text(collector_id), + terminal_text(scope_id) + ); + } + + match scope_id { + "current_user.shared.run" => "Current-user Run startup".to_owned(), + "current_user.shared.run_once" => "Current-user RunOnce startup".to_owned(), + "local_machine.native.run" => "Machine-wide Run startup".to_owned(), + "local_machine.native.run_once" => "Machine-wide RunOnce startup".to_owned(), + "local_machine.registry32.run" => { + "Machine-wide Run startup (32-bit Registry view)".to_owned() + } + "local_machine.registry32.run_once" => { + "Machine-wide RunOnce startup (32-bit Registry view)".to_owned() + } + "local_machine.registry64.run" => { + "Machine-wide Run startup (64-bit Registry view)".to_owned() + } + "local_machine.registry64.run_once" => { + "Machine-wide RunOnce startup (64-bit Registry view)".to_owned() + } + _ => format!( + "{}/{}", + terminal_text(collector_id), + terminal_text(scope_id) + ), + } +} + +fn technical_view(view: RegistryView) -> &'static str { + match view { + RegistryView::Shared => "shared", + RegistryView::Native => "native", + RegistryView::Registry32 => "registry32", + RegistryView::Registry64 => "registry64", + } +} + +fn technical_prefix(prefix: RunOncePrefixSemantics) -> &'static str { + match prefix { + RunOncePrefixSemantics::NoDocumentedPrefix => "no_documented_prefix", + RunOncePrefixSemantics::DeferDeletionUntilAfterRun => "defer_deletion_until_after_run", + RunOncePrefixSemantics::RunInSafeMode => "run_in_safe_mode", + RunOncePrefixSemantics::Undocumented => "undocumented", + } +} + +fn decode_status(decoding: &RegistryValueDecoding) -> &'static str { + match decoding { + RegistryValueDecoding::Decoded { .. } => "decoded", + RegistryValueDecoding::NotApplicable => "not_applicable", + RegistryValueDecoding::InvalidData => "invalid_data", + RegistryValueDecoding::UnsupportedType => "unsupported_type", + } +} + +fn registry_type_name(value_type: u32) -> &'static str { + match value_type { + 0 => "REG_NONE", + 1 => "REG_SZ", + 2 => "REG_EXPAND_SZ", + 3 => "REG_BINARY", + 4 => "REG_DWORD", + 5 => "REG_DWORD_BIG_ENDIAN", + 6 => "REG_LINK", + 7 => "REG_MULTI_SZ", + 8 => "REG_RESOURCE_LIST", + 9 => "REG_FULL_RESOURCE_DESCRIPTOR", + 10 => "REG_RESOURCE_REQUIREMENTS_LIST", + 11 => "REG_QWORD", + _ => "unknown native type", + } +} + +fn status(status: CollectorStatus) -> &'static str { + match status { + CollectorStatus::Complete => "complete", + CollectorStatus::Partial => "partial", + CollectorStatus::PermissionDenied => "permission denied", + CollectorStatus::Unavailable => "unavailable", + CollectorStatus::Unsupported => "unsupported", + CollectorStatus::Failed => "failed", + } +} + +fn optional_status(value: Option) -> &'static str { + value.map(status).unwrap_or("not present") +} + +fn inconclusive_reason(reason: systemdiff_diff::InconclusiveReason) -> &'static str { + match reason { + systemdiff_diff::InconclusiveReason::CoverageIncomplete => "coverage_incomplete", + } +} + +fn change_index(change: &ChangeKind) -> usize { + match change { + ChangeKind::Added { .. } => 0, + ChangeKind::Modified { .. } => 1, + ChangeKind::Removed { .. } => 2, + ChangeKind::Inconclusive { .. } => 3, + ChangeKind::Unchanged { .. } => 4, + } +} + +fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { + if count == 1 { singular } else { plural } } #[derive(Debug)] @@ -91,6 +958,7 @@ impl Error for ReportError { #[cfg(test)] mod tests { use super::*; + use systemdiff_diff::DiffDocument; fn empty_diff() -> DiffDocument { DiffDocument { @@ -119,11 +987,10 @@ mod tests { assert_eq!( output, concat!( - "SystemDiff diff\n", - "Before: 2026-08-11T00:00:00Z\n", - "After: 2026-08-11T00:05:00Z\n", - "Changes: none\n" + "No changes found\n", + "Compared 2026-08-11T00:00:00Z -> 2026-08-11T00:05:00Z\n" ) ); + assert!(!output.contains('\u{1b}')); } } diff --git a/crates/systemdiff-report/tests/human_diff.rs b/crates/systemdiff-report/tests/human_diff.rs new file mode 100644 index 0000000..04b82eb --- /dev/null +++ b/crates/systemdiff-report/tests/human_diff.rs @@ -0,0 +1,617 @@ +use systemdiff_core::{ + Artifact, ArtifactKey, CollectorStatus, RegistryDecodedValue, RegistryHive, + RegistryStartupEntry, RegistryStartupKind, RegistryValueDecoding, RegistryValueName, + RegistryView, RunOncePrefixSemantics, Snapshot, +}; +use systemdiff_diff::{ + ArtifactChange, ChangeKind, DiffDocument, DiffOptions, DiffWarning, DiffWarningCode, + InconclusiveReason, diff_snapshots, +}; +use systemdiff_report::{render_technical, render_terminal, write_json}; + +const BEFORE_CAPTURED_AT: &str = "2026-08-11T00:00:00Z"; +const AFTER_CAPTURED_AT: &str = "2026-08-11T00:05:00Z"; +const RUN_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\Run"; +const RUN_ONCE_PATH: &str = "Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce"; + +fn decoded_string(value: &str) -> RegistryValueDecoding { + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::String { + value: value.to_owned(), + }, + } +} + +fn run_entry( + name: RegistryValueName, + decoding: RegistryValueDecoding, + hash_byte: char, +) -> Artifact { + Artifact::RegistryStartup(RegistryStartupEntry { + hive: RegistryHive::CurrentUser, + registry_view: RegistryView::Shared, + key_path: RUN_PATH.to_owned(), + value_name: name, + startup_kind: RegistryStartupKind::Run, + run_once_prefix: None, + value_type: if matches!(&decoding, RegistryValueDecoding::Decoded { .. }) { + 1 + } else { + 3 + }, + content_sha256: hash_byte.to_string().repeat(64), + decoding, + raw_evidence: None, + }) +} + +fn run_once_entry(name: &str, command: &str, hash_byte: char) -> Artifact { + let prefix = if name.starts_with('!') { + RunOncePrefixSemantics::DeferDeletionUntilAfterRun + } else if name.starts_with('*') { + RunOncePrefixSemantics::RunInSafeMode + } else { + RunOncePrefixSemantics::NoDocumentedPrefix + }; + Artifact::RegistryStartup(RegistryStartupEntry { + hive: RegistryHive::LocalMachine, + registry_view: RegistryView::Registry64, + key_path: RUN_ONCE_PATH.to_owned(), + value_name: RegistryValueName::decoded(name), + startup_kind: RegistryStartupKind::RunOnce, + run_once_prefix: Some(prefix), + value_type: 1, + content_sha256: hash_byte.to_string().repeat(64), + decoding: decoded_string(command), + raw_evidence: None, + }) +} + +fn key(scope_id: &str, canonical_id: &str) -> ArtifactKey { + ArtifactKey { + collector_id: "windows.registry.startup".to_owned(), + scope_id: scope_id.to_owned(), + artifact_kind: "registry_startup".to_owned(), + canonical_id: canonical_id.to_owned(), + } +} + +fn change( + change_id: &str, + scope_id: &str, + canonical_id: &str, + change: ChangeKind, +) -> ArtifactChange { + ArtifactChange { + change_id: change_id.to_owned(), + key: key(scope_id, canonical_id), + change, + } +} + +fn diff(changes: Vec) -> DiffDocument { + DiffDocument { + document_type: "systemdiff.diff".to_owned(), + schema_version: 1, + before_captured_at: BEFORE_CAPTURED_AT.to_owned(), + after_captured_at: AFTER_CAPTURED_AT.to_owned(), + changes, + warnings: Vec::new(), + } +} + +fn before_fixture() -> Snapshot { + serde_json::from_str(include_str!("../../../fixtures/snapshots/before-v1.json")) + .expect("the before fixture must deserialize") +} + +fn after_fixture() -> Snapshot { + serde_json::from_str(include_str!("../../../fixtures/snapshots/after-v1.json")) + .expect("the after fixture must deserialize") +} + +fn occurrence_count(haystack: &str, needle: &str) -> usize { + haystack.match_indices(needle).count() +} + +#[test] +fn added_run_and_run_once_entries_are_recognizable_without_opaque_ids() { + let run = run_entry( + RegistryValueName::decoded("ExampleUpdater"), + decoded_string("C:\\Example\\updater.exe --background"), + 'a', + ); + let run_once = run_once_entry("!FinishInstall", "C:\\Example\\finish-install.exe", 'b'); + let document = diff(vec![ + change( + "change:v1:00000000", + "current_user.shared.run", + "opaque-run-identity", + ChangeKind::Added { after: run }, + ), + change( + "change:v1:00000001", + "local_machine.registry64.run_once", + "opaque-run-once-identity", + ChangeKind::Added { after: run_once }, + ), + ]); + + let output = render_terminal(&document); + + assert!(output.contains("Added")); + assert!(output.contains("ExampleUpdater")); + assert!(output.contains("C:\\Example\\updater.exe --background")); + assert!(output.contains("Run")); + assert!(output.contains("!FinishInstall")); + assert!(output.contains("C:\\Example\\finish-install.exe")); + assert!(output.contains("RunOnce")); + assert!(!output.contains("opaque-run-identity")); + assert!(!output.contains(&"a".repeat(64))); +} + +#[test] +fn modified_decoded_entry_shows_before_and_after_values() { + let before = run_entry( + RegistryValueName::decoded("ExampleApp"), + decoded_string("C:\\Example\\example.exe --old"), + 'a', + ); + let after = run_entry( + RegistryValueName::decoded("ExampleApp"), + decoded_string("C:\\Example\\example.exe --new"), + 'b', + ); + let document = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "example-app", + ChangeKind::Modified { before, after }, + )]); + + let output = render_terminal(&document); + + assert!(output.contains("Modified")); + assert!(output.contains("ExampleApp")); + let before_index = output + .find("C:\\Example\\example.exe --old") + .expect("the previous decoded value must be visible"); + let after_index = output + .find("C:\\Example\\example.exe --new") + .expect("the new decoded value must be visible"); + assert!( + before_index < after_index, + "before evidence must precede after evidence" + ); +} + +#[test] +fn modified_native_evidence_does_not_claim_an_unchanged_command_changed() { + let before = run_entry( + RegistryValueName::decoded("ExampleApp"), + decoded_string("C:\\Example\\example.exe"), + 'a', + ); + let mut after = run_entry( + RegistryValueName::decoded("ExampleApp"), + decoded_string("C:\\Example\\example.exe"), + 'b', + ); + if let Artifact::RegistryStartup(entry) = &mut after { + entry.value_type = 2; + entry.decoding = RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::ExpandString { + value: "C:\\Example\\example.exe".to_owned(), + }, + }; + } + let document = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "example-app", + ChangeKind::Modified { before, after }, + )]); + + let output = render_terminal(&document); + + assert!(output.contains("Registry startup evidence changed")); + assert!(!output.contains("Startup command changed")); +} + +#[test] +fn removed_entry_is_confirmed_but_inconclusive_absence_stays_uncertain() { + let artifact = run_entry( + RegistryValueName::decoded("LegacyUpdater"), + decoded_string("C:\\Legacy\\updater.exe"), + 'a', + ); + let removed = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "legacy-updater", + ChangeKind::Removed { + before: artifact.clone(), + }, + )]); + let inconclusive = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "legacy-updater", + ChangeKind::Inconclusive { + before: Some(artifact), + after: None, + reason: InconclusiveReason::CoverageIncomplete, + }, + )]); + + let removed_output = render_terminal(&removed); + let inconclusive_output = render_terminal(&inconclusive); + + assert!(removed_output.contains("Removed")); + assert!(removed_output.contains("LegacyUpdater")); + assert!(inconclusive_output.contains("LegacyUpdater")); + assert!(inconclusive_output.contains("Could not confirm 1 possible change")); + assert!( + inconclusive_output + .to_ascii_lowercase() + .contains("inconclusive") + ); + assert!( + inconclusive_output + .to_ascii_lowercase() + .contains("coverage") + ); + assert!(!inconclusive_output.contains("Removed")); +} + +#[test] +fn undecoded_registry_data_is_not_presented_as_a_command() { + let artifact = run_entry( + RegistryValueName::decoded("BinaryStartupValue"), + RegistryValueDecoding::UnsupportedType, + 'c', + ); + let document = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "binary-value", + ChangeKind::Added { after: artifact }, + )]); + + let output = render_terminal(&document); + + assert!(output.contains("BinaryStartupValue")); + assert!(output.to_ascii_lowercase().contains("not decoded")); + assert!(!output.contains("Command:")); + assert!(!output.contains(&"c".repeat(64))); +} + +#[test] +fn invalid_utf16_and_unnamed_value_names_have_explicit_human_labels() { + let invalid_name = RegistryValueName::InvalidUtf16 { + utf16le_hex: "00d85800".to_owned(), + }; + let invalid = run_entry( + invalid_name, + decoded_string("C:\\Example\\invalid.exe"), + 'a', + ); + let unnamed = run_entry( + RegistryValueName::decoded(""), + decoded_string("C:\\Example\\default.exe"), + 'b', + ); + let document = diff(vec![ + change( + "change:v1:00000000", + "current_user.shared.run", + "invalid-name", + ChangeKind::Added { after: invalid }, + ), + change( + "change:v1:00000001", + "current_user.shared.run", + "unnamed-value", + ChangeKind::Added { after: unnamed }, + ), + ]); + + let output = render_terminal(&document); + + assert!(output.contains("Name could not be decoded as UTF-16")); + assert!(output.contains("Default value (unnamed)")); + assert!(!output.contains("invalid-name")); +} + +#[test] +fn multiple_changes_share_one_registry_group_and_empty_diff_is_calm() { + let first = run_entry( + RegistryValueName::decoded("First"), + decoded_string("C:\\Example\\first.exe"), + 'a', + ); + let second = run_once_entry("Second", "C:\\Example\\second.exe", 'b'); + let document = diff(vec![ + change( + "change:v1:00000000", + "current_user.shared.run", + "first", + ChangeKind::Added { after: first }, + ), + change( + "change:v1:00000001", + "local_machine.registry64.run_once", + "second", + ChangeKind::Added { after: second }, + ), + ]); + + let output = render_terminal(&document); + let empty_output = render_terminal(&diff(Vec::new())); + + assert_eq!(occurrence_count(&output, "Registry startup changes"), 1); + assert!( + output.find("First").expect("First must render") + < output.find("Second").expect("Second must render") + ); + assert!(empty_output.contains("No changes")); +} + +#[test] +fn partial_coverage_warning_is_prominent_and_preserves_status_context() { + let artifact = run_entry( + RegistryValueName::decoded("MaybeGone"), + decoded_string("C:\\Example\\maybe.exe"), + 'a', + ); + let mut document = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "maybe-gone", + ChangeKind::Inconclusive { + before: Some(artifact), + after: None, + reason: InconclusiveReason::CoverageIncomplete, + }, + )]); + document.warnings.push(DiffWarning { + code: DiffWarningCode::CoverageIncomplete, + collector_id: "windows.registry.startup".to_owned(), + scope_id: "current_user.shared.run".to_owned(), + before_status: Some(CollectorStatus::Complete), + after_status: Some(CollectorStatus::Partial), + }); + + let output = render_terminal(&document); + + assert!(output.to_ascii_lowercase().contains("coverage")); + assert!(output.contains("Current-user Run startup")); + assert!(!output.contains("current_user.shared.run")); + assert!(output.to_ascii_lowercase().contains("complete")); + assert!(output.to_ascii_lowercase().contains("partial")); + assert!(!output.contains("Removed")); +} + +#[test] +fn observed_control_characters_cannot_inject_lines_or_ansi_sequences() { + let artifact = run_entry( + RegistryValueName::decoded("Evil\nName\u{1b}[31m\u{2028}\u{202e}"), + decoded_string("C:\\Example\\evil.exe\r\nInjected\targument\u{7}"), + 'a', + ); + let document = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "control-value", + ChangeKind::Added { after: artifact }, + )]); + let before = before_fixture(); + let after = after_fixture(); + + for output in [ + render_terminal(&document), + render_technical(&document, &before, &after), + ] { + assert!( + !output.contains('\u{1b}'), + "raw ANSI escape must never reach output" + ); + assert!(!output.contains('\r')); + assert!(!output.contains('\t')); + assert!(!output.contains('\u{7}')); + assert!(!output.contains('\u{202e}')); + assert!(!output.contains('\u{2028}')); + assert!( + !output + .lines() + .any(|line| line == "Name" || line == "Injected") + ); + assert!(output.contains("Evil\\nName")); + assert!(output.contains("\\u{202e}")); + assert!(output.contains("\\u{2028}")); + assert!(output.contains("\\r\\nInjected\\targument")); + } +} + +#[test] +fn technical_mode_keeps_lossless_invalid_and_unnamed_value_names() { + let invalid = run_entry( + RegistryValueName::InvalidUtf16 { + utf16le_hex: "00d85800".to_owned(), + }, + decoded_string("C:\\Example\\invalid.exe"), + 'a', + ); + let unnamed = run_entry( + RegistryValueName::decoded(""), + decoded_string("C:\\Example\\default.exe"), + 'b', + ); + let document = diff(vec![ + change( + "change:v1:00000000", + "current_user.shared.run", + "invalid-name", + ChangeKind::Added { after: invalid }, + ), + change( + "change:v1:00000001", + "current_user.shared.run", + "unnamed-value", + ChangeKind::Added { after: unnamed }, + ), + ]); + + let output = render_technical(&document, &before_fixture(), &after_fixture()); + + assert!(output.contains("value name encoding: invalid_utf16")); + assert!(output.contains("value name UTF-16LE hex: 00d85800")); + assert!(output.contains("value name encoding: decoded")); + assert!(output.contains("value name: ")); +} + +#[test] +fn technical_mode_exposes_exact_evidence_versions_and_scoped_diagnostics() { + let before = before_fixture(); + let after = after_fixture(); + let document = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("the broad fixtures must produce a deterministic diff"); + + let output = render_technical(&document, &before, &after); + + for expected in [ + "windows.registry.startup", + "version: 1", + "current_user.shared.run", + "registry_startup", + "05b14fce21e4232e9c47bc29ba58737949d4c17c78c8f2efb289128644c60603", + "current_user", + "shared", + RUN_PATH, + "ExampleApp", + "value type: 1", + "decoded", + r#"C:\\Example\\example.exe --background"#, + r#"C:\\Example\\example.exe --background --updated"#, + "b27cce45267b6100cdd3267ec6dcdf2023e6846bb3f0f66162ab179678f0727c", + "e3f7174dd4ae12dc6be7e6d17d598e035cd03bf994008d3e8866c15183640f35", + "task_folder_access_denied", + "enumerate_folder", + "-2147024891", + ] { + assert!( + output.contains(expected), + "technical output omitted {expected:?}\n{output}" + ); + } +} + +#[test] +fn technical_mode_lists_snapshot_coverage_even_without_observations_or_changes() { + let before: Snapshot = serde_json::from_str(include_str!( + "../../../fixtures/snapshots/registry-before-v1.json" + )) + .expect("the Registry before fixture must deserialize"); + let mut after = before.clone(); + after.captured_at = AFTER_CAPTURED_AT.to_owned(); + after.collectors[0].status = CollectorStatus::Partial; + after.collectors[0].coverage[0].status = CollectorStatus::Partial; + let document = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("an empty partial Registry scope must produce a Diff"); + assert!(document.changes.is_empty()); + assert_eq!(document.warnings.len(), 1); + + let output = render_technical(&document, &before, &after); + + assert!(output.contains("Before Snapshot collector coverage")); + assert!(output.contains("After Snapshot collector coverage")); + assert!(output.contains("collector ID: windows.registry.startup")); + assert!(output.contains("version: 1")); + assert!(output.contains("aggregate status: complete")); + assert!(output.contains("aggregate status: partial")); + assert!(output.contains("scope current_user.shared.run: complete")); + assert!(output.contains("scope current_user.shared.run: partial")); +} + +#[test] +fn technical_multi_string_keeps_element_boundaries_and_empty_values() { + let mut artifact = run_entry( + RegistryValueName::decoded("MultiValue"), + RegistryValueDecoding::Decoded { + value: RegistryDecodedValue::MultiString { + values: vec!["a, b".to_owned(), String::new(), "c".to_owned()], + }, + }, + 'a', + ); + if let Artifact::RegistryStartup(entry) = &mut artifact { + entry.value_type = 7; + } + let document = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "multi-value", + ChangeKind::Added { after: artifact }, + )]); + + let output = render_technical(&document, &before_fixture(), &after_fixture()); + + assert!(output.contains(r#"multi_string (3 elements): [0]="a, b"; [1]=""; [2]="c""#)); +} + +#[test] +fn json_output_remains_the_pretty_serialized_diff_with_one_trailing_newline() { + let artifact = run_entry( + RegistryValueName::decoded("JsonContract"), + decoded_string("C:\\Example\\json.exe"), + 'd', + ); + let mut document = diff(vec![change( + "change:v1:00000000", + "current_user.shared.run", + "json-contract", + ChangeKind::Added { after: artifact }, + )]); + document.warnings.push(DiffWarning { + code: DiffWarningCode::CoverageIncomplete, + collector_id: "windows.registry.startup".to_owned(), + scope_id: "local_machine.registry64.run".to_owned(), + before_status: Some(CollectorStatus::Complete), + after_status: Some(CollectorStatus::PermissionDenied), + }); + + let mut bytes = Vec::new(); + write_json(&mut bytes, &document).expect("representative diff JSON must render"); + let output = String::from_utf8(bytes).expect("JSON output must be UTF-8"); + let expected = serde_json::to_string_pretty(&document).expect("Diff must serialize") + "\n"; + assert_eq!(output, expected); + + let value: serde_json::Value = serde_json::from_str(&output).expect("output must be JSON"); + assert_eq!(value["document_type"], "systemdiff.diff"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["changes"][0]["change"]["change"], "added"); + assert_eq!( + value["changes"][0]["change"]["after"]["evidence"]["value_name"]["value"], + "JsonContract" + ); + assert_eq!(value["warnings"][0]["code"], "coverage_incomplete"); +} + +#[test] +fn registry_demo_fixture_matches_the_published_human_transcript() { + let before: Snapshot = serde_json::from_str(include_str!( + "../../../fixtures/snapshots/registry-before-v1.json" + )) + .expect("the Registry before fixture must deserialize"); + let after: Snapshot = serde_json::from_str(include_str!( + "../../../fixtures/snapshots/registry-after-v1.json" + )) + .expect("the Registry after fixture must deserialize"); + let document = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("the Registry demo fixtures must diff"); + + assert_eq!( + render_terminal(&document), + include_str!("../../../fixtures/reports/registry-added-human.txt") + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index 9091c73..e17216e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,7 +101,13 @@ User-facing `en-US` and `zh-CN` strings are resolved outside the rule engine. A ### Report -Reports initially support deterministic JSON and a human-readable terminal view. Renderers receive typed results and an output stream. They do not rescan the system or execute evidence. +Reports support three deliberately separate views over the same typed evidence: + +- the default human-readable terminal view leads with recognizable, factual changes and calm coverage limitations; +- the explicit technical terminal view retains Collector version/scope, canonical identity, native evidence, hashes, decode status, and Snapshot diagnostics; +- deterministic JSON preserves the versioned, language-neutral Diff wire document for tools. + +The human renderer consumes a `DiffDocument`. The technical renderer also receives the two already validated source Snapshots because Collector versions and scoped diagnostics are Snapshot evidence and are intentionally not duplicated into Diff v1. The CLI only selects a renderer; presentation stays in `systemdiff-report`. Terminal renderers escape control characters from untrusted observed strings and do not rely on color or ANSI formatting. Renderers do not rescan the system, execute evidence, or open arbitrary files. ## Windows collection strategy diff --git a/docs/assets/README.md b/docs/assets/README.md new file mode 100644 index 0000000..229d577 --- /dev/null +++ b/docs/assets/README.md @@ -0,0 +1,13 @@ +# Demo assets + +`registry-startup-demo.svg` is a static rendering of the exact human-readable output stored in `fixtures/reports/registry-added-human.txt`. The transcript is generated from the two synthetic Registry-only Snapshot fixtures and is enforced by a report regression test. + +Reproduce the source output from the repository root: + +```powershell +cargo run --locked --quiet -p systemdiff-cli -- diff fixtures/snapshots/registry-before-v1.json fixtures/snapshots/registry-after-v1.json +``` + +The fixtures contain one clearly synthetic `HKCU\\...\\Run` value. They contain no real host data and do not claim Services, Scheduled Tasks, signatures, rules, or risk classification. If renderer wording changes, update the transcript first from verified output, run the report tests, and then update the SVG to match it exactly. + +An eventual animated recording should use the same guarded synthetic workflow: capture before, add the unique test-only HKCU value through the existing dual-gated harness, capture after, show the human Diff, verify exact-data cleanup, and retain no real Snapshot. diff --git a/docs/assets/registry-startup-demo.svg b/docs/assets/registry-startup-demo.svg new file mode 100644 index 0000000..92fa3b6 --- /dev/null +++ b/docs/assets/registry-startup-demo.svg @@ -0,0 +1,25 @@ + + SystemDiff synthetic Registry startup demo + Verified SystemDiff terminal output showing one synthetic current-user Run startup value added. + + + + + + + + Synthetic Registry-only demo · verified CLI output + + 1 confirmed change + Compared 2026-08-11T00:00:00Z -> 2026-08-11T00:01:00Z + Registry startup changes + + SystemDiffSyntheticE2E + Added (Run) + Added to current-user startup + Command + C:\Synthetic\example.exe + Location + HKCU\Software\Microsoft\Windows\CurrentVersion\Run + + Factual Registry evidence only · no severity, signature, or malware verdict inferred + diff --git a/fixtures/reports/registry-added-human.txt b/fixtures/reports/registry-added-human.txt new file mode 100644 index 0000000..8063a79 --- /dev/null +++ b/fixtures/reports/registry-added-human.txt @@ -0,0 +1,14 @@ +1 confirmed change +Compared 2026-08-11T00:00:00Z -> 2026-08-11T00:01:00Z + +Registry startup changes + + + SystemDiffSyntheticE2E + Added (Run) + Added to current-user startup + + Command + C:\Synthetic\example.exe + + Location + HKCU\Software\Microsoft\Windows\CurrentVersion\Run diff --git a/scripts/run-registry-startup-e2e.ps1 b/scripts/run-registry-startup-e2e.ps1 index d600259..d1164c2 100644 --- a/scripts/run-registry-startup-e2e.ps1 +++ b/scripts/run-registry-startup-e2e.ps1 @@ -195,10 +195,32 @@ try { throw 'The Added change did not match the exact synthetic HKCU Shared Run evidence identity.' } + $humanDiff = (Invoke-SystemDiff -Executable $systemdiff -Arguments @('diff', $beforePath, $afterPath)) -join [Environment]::NewLine + if ( + -not $humanDiff.Contains('1 confirmed change') -or + -not $humanDiff.Contains($valueName) -or + -not $humanDiff.Contains('Added to current-user startup') -or + -not $humanDiff.Contains('HKCU\Software\Microsoft\Windows\CurrentVersion\Run') + ) { + throw 'The human-readable Diff did not present the exact synthetic Registry startup change.' + } + + $technicalDiff = (Invoke-SystemDiff -Executable $systemdiff -Arguments @('diff', '--technical', $beforePath, $afterPath)) -join [Environment]::NewLine + if ( + -not $technicalDiff.Contains('windows.registry.startup') -or + -not $technicalDiff.Contains('version: 1') -or + -not $technicalDiff.Contains('current_user.shared.run') -or + -not $technicalDiff.Contains($change.key.canonical_id) -or + -not $technicalDiff.Contains($evidence.content_sha256) + ) { + throw 'The technical Diff did not preserve the exact synthetic Registry evidence.' + } + 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' + Write-Output 'Renderers: human and technical evidence verified' } finally { $cleanupFailure = $null