From 341c8b36f0adbfecb4126594e65f2722a8971073 Mon Sep 17 00:00:00 2001 From: Xiaoju Date: Thu, 13 Aug 2026 16:52:25 +0800 Subject: [PATCH 1/3] feat(windows): implement Services collector --- .agent/PROJECT_STATE.md | 17 +- .agent/plans/windows-services-collector.md | 182 +++++ .github/workflows/ci.yml | 4 +- Cargo.lock | 1 + README.md | 12 +- README.zh-CN.md | 12 +- crates/systemdiff-cli/src/main.rs | 24 +- crates/systemdiff-core/Cargo.toml | 1 + crates/systemdiff-core/src/lib.rs | 155 +++- .../systemdiff-core/tests/snapshot_fixture.rs | 1 + .../systemdiff-core/tests/windows_services.rs | 136 ++++ crates/systemdiff-diff/tests/snapshot_diff.rs | 103 ++- crates/systemdiff-report/src/lib.rs | 382 +++++++++- crates/systemdiff-report/tests/human_diff.rs | 301 +++++++- crates/systemdiff-windows/Cargo.toml | 1 + crates/systemdiff-windows/src/lib.rs | 69 +- crates/systemdiff-windows/src/services.rs | 587 +++++++++++++++ .../systemdiff-windows/src/win32_services.rs | 693 ++++++++++++++++++ docs/architecture.md | 4 +- docs/collectors.md | 12 +- docs/data-format.md | 4 + docs/roadmap.md | 4 +- docs/threat-model.md | 8 +- fixtures/snapshots/after-v1.json | 22 +- fixtures/snapshots/before-v1.json | 22 +- .../snapshots/services-added-after-v1.json | 61 ++ .../snapshots/services-added-before-v1.json | 34 + packaging/windows/QUICKSTART.md | 6 +- scripts/verify-windows-preview.ps1 | 17 +- 29 files changed, 2781 insertions(+), 94 deletions(-) create mode 100644 .agent/plans/windows-services-collector.md create mode 100644 crates/systemdiff-core/tests/windows_services.rs create mode 100644 crates/systemdiff-windows/src/services.rs create mode 100644 crates/systemdiff-windows/src/win32_services.rs create mode 100644 fixtures/snapshots/services-added-after-v1.json create mode 100644 fixtures/snapshots/services-added-before-v1.json diff --git a/.agent/PROJECT_STATE.md b/.agent/PROJECT_STATE.md index 47465c1..effbf00 100644 --- a/.agent/PROJECT_STATE.md +++ b/.agent/PROJECT_STATE.md @@ -4,7 +4,7 @@ Last updated: 2026-08-13 ## Current phase -The public foundation, pre-collector hardening, first real Windows product slice, and stranger-first Diff presentation are on `main`. PR #8 is merged: SystemDiff has default human-readable Registry Diff output, exact `--technical` evidence, deterministic `--json`, and a truthful Registry-only front page. The current `build/portable-developer-preview` work for Issue #9 adds an unsigned, CI-generated Windows x64 package so the existing product slice can be exercised without a Rust development environment. v0.1 remains incomplete. +The public foundation, Registry startup vertical slice, human-readable Diff, and portable Windows x64 Developer Preview are on `main`. The current `feat/windows-services` work for Issue #11 adds the second real Collector while preserving the read-only and no-false-removal boundaries. v0.1 remains incomplete. ## Implemented components @@ -13,21 +13,23 @@ The public foundation, pre-collector hardening, first real Windows product slice - 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. +- On the current Issue #11 branch, `windows.services` v1 uses query-only SCM APIs, atomic selected-field observations, strict UTF-16 handling, bounded resources/mutation reads, and permanently conservative current-token partial coverage. - `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 #9 branch, a release-mode Windows x64 Developer Preview pipeline with packaging-only static MSVC CRT, an explicit `asInvoker` manifest, exact package/checksum verification, and a later artifact-download smoke job. +- Default human-readable Registry and Services Diff output, explicit `--technical` evidence output, and unchanged `--json` machine output. +- A release-mode Windows x64 Developer Preview pipeline with packaging-only static MSVC CRT, an explicit `asInvoker` manifest, exact package/checksum verification, and a later artifact-download smoke job. - 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, an installer, and the desktop app are unavailable. +- Registry Run/RunOnce is implemented on `main`; Services is implemented on the current feature branch. Scheduled Tasks, rules/explanations, sanitization, an installer, and the desktop app are unavailable. - There is no official binary Release or Authenticode signing. The Developer Preview is an expiring GitHub Actions artifact that requires GitHub sign-in, and clean-machine validation remains a gate for an official alpha. - 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. +- Snapshot files are unredacted and can contain usernames, service accounts, paths/arguments, descriptions, 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. +- SCM service-name comparison is also case-insensitive. Services v1 preserves exact returned UTF-16 evidence and accepts the same conservative casing-only false-split limitation rather than applying unverified Unicode/NLS normalization. - 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. @@ -39,15 +41,16 @@ The public foundation, pre-collector hardening, first real Windows product slice - 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. +- Services capture limits are 4,096 services, 32 KiB retained UTF-16 evidence per service, 16 MiB per Collector, 64 enumeration pages, and documented 256 KiB/8 KiB native enumeration/query buffer ceilings. The real `current_token.win32` scope is always partial because SCM can silently omit services inaccessible for status queries. - 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)`. -- Developer Preview packaging runs only after those gates on trusted upstream `push` events, uploads an exact ZIP/checksum pair for 14 days, and verifies the downloaded artifact in a fresh Windows job. Normal artifacts come from `main`; the exact Issue #9 branch temporarily produces a clearly named candidate for pre-merge validation. Fork pull requests cannot enter this upload path. +- Developer Preview packaging runs only after those gates on trusted upstream `push` events, uploads an exact ZIP/checksum pair for 14 days, and verifies the downloaded artifact in a fresh Windows job. Normal artifacts come from `main`; the exact active feature branch may temporarily produce a clearly named candidate for pre-merge validation. Fork pull requests cannot enter this upload path. - The portable build alone uses static MSVC CRT and remains at version `0.0.0`. The package is commit-linked and hashed but is not claimed to be reproducible, signed, released, or permanently downloadable. ## Next milestone -Finish implementation, independent review, remote CI, and downloaded-artifact verification for Issue #9 without merging automatically. A signed, permanent public download remains future release work. Services remains the next Collector candidate but is not started in this productization PR. +Finish implementation, independent review, real read-only Windows validation, remote CI, and downloaded-artifact verification for Issue #11 without merging automatically. Scheduled Tasks remains intentionally unstarted; a signed, permanent public download remains future release work. ## Major unresolved questions diff --git a/.agent/plans/windows-services-collector.md b/.agent/plans/windows-services-collector.md new file mode 100644 index 0000000..c0174f1 --- /dev/null +++ b/.agent/plans/windows-services-collector.md @@ -0,0 +1,182 @@ +# Windows Services Collector v1 + +Status: In progress +Owner: primary agent +Last updated: 2026-08-13 + +## Goal + +Issue #11 adds SystemDiff's second real Windows Collector: read-only, deterministic Win32 service configuration evidence through `windows.services` v1. The implementation must broaden the usable Snapshot -> Diff pipeline without weakening the existing rule that incomplete visibility cannot become a confirmed removal. + +## User-visible outcome + +`systemdiff snapshot -o ` captures Registry Run/RunOnce evidence and Windows service configuration visible to the current token. Default Diff output gives ordinary users a dedicated Windows service section with factual Added/Modified/Removed/Inconclusive wording and field-level changes; `--technical` exposes the raw service configuration and coverage evidence; `--json` remains deterministic and language-neutral. The portable Windows x64 Developer Preview contains and verifies both implemented Collectors. + +## Current architecture and context + +- `systemdiff-core` already has a draft `WindowsService` artifact, but it has no Service-specific validation or canonical identity contract. Its optional fields cannot distinguish known absence from a failed query. +- At plan creation, `systemdiff-windows::capture_snapshot` ran only `RegistryStartupCollector` and `mvp_collector_plans` marked Services planned; implementation now runs both independently. +- `systemdiff-diff` indexes `(collector_id, scope_id, artifact_kind, canonical_id)`. It confirms a one-sided absence only when the relevant scope is complete on both sides. Directly observed same-identity evidence can still be Modified under partial coverage. +- `EnumServicesStatusExW` explicitly and silently omits a service when the caller lacks `SERVICE_QUERY_STATUS`. A successful enumeration therefore is not proof that the service database was exhaustively visible to the current token. +- The current broad fixtures use placeholder scope `machine.win32`, plaintext lowercase identities, complete coverage, and a draft Service wire shape. They are not evidence of a shipped Collector contract. +- The portable verifier currently assumes Registry is the only implemented/enabled Collector. + +## Constraints + +- Production is query-only. Do not bind or call service creation, deletion, configuration mutation, start/stop/pause, control, or elevation APIs. +- Use `OpenSCManagerW`, `EnumServicesStatusExW`, `OpenServiceW`, `QueryServiceConfigW`, `QueryServiceConfig2W`, and `CloseServiceHandle`; do not shell out or parse localized command output. +- Request only `SC_MANAGER_ENUMERATE_SERVICE` and `SERVICE_QUERY_CONFIG`. +- Enumerate active and inactive Win32 services. Include own-process, shared-process, interactive modifiers, and modern per-user service/instance modifiers when their native type also identifies a Win32 service. Exclude kernel, file-system, recognizer, and other driver-only types by native type bits, never by name. +- Preserve API text as evidence. Do not expand variables, parse command lines, resolve/hash executables, resolve accounts/resources, infer publisher, or classify risk. +- Reject malformed UTF-16 for the affected item. Never use lossy replacement as evidence. +- Snapshot/report output is unredacted and may expose service accounts, paths/arguments, dependency/vendor names, load groups, and descriptions. +- No default CI or local validation may create, delete, reconfigure, start, or stop a service or require administrator privileges. + +## Implementation steps + +1. Finalize the pre-v0.1 Service wire contract and tests. + - Keep existing fields and add `load_order_group: Option` plus `tag_id: Option` because `QUERY_SERVICE_CONFIGW` returns them and public Collector docs already promise them. + - Treat missing/empty load group and zero tag as known configured absence only after a successful base query. + - Do not add serde defaults for the new fields: older draft service artifacts lacking them are rejected rather than silently reinterpreted. Update every committed draft fixture and document the deliberate compatibility impact. Keep Snapshot schema v1 because no public v0.1 format has shipped. + - Validate non-empty service name, no embedded NUL, bounded strings/dependencies, a Win32 base type with no driver-only bits, Service Collector/artifact/scope association, and the v1 canonical identity. Preserve start type and delayed-auto-start independently: real Windows validation showed the delayed flag can remain set on a currently non-automatic service, so rejecting that combination would discard evidence. +2. Define identity and field semantics. + - Service name is the only identity input. Display name, status, PID, config, and enumeration order never affect identity. + - v1 canonical identity is lowercase hexadecimal SHA-256 over domain `systemdiff.windows-services.identity.v1\0`, the UTF-16 unit count as little-endian `u32`, and exact service-name UTF-16 units as little-endian bytes. + - SCM preserves service-name case and compares names case-insensitively, but Microsoft exposes no documented persistent cross-platform canonical token. v1 therefore does not perform Unicode/NLS folding. It can false-split a hypothetical returned casing change; this conservative limitation is documented and any correction requires a new Collector version/compatibility decision. + - Preserve complete per-user `_LUID` suffixes. + - Preserve dependency order, casing, and `SC_GROUP_IDENTIFIER` (`+`) prefixes exactly as the configured MULTI_SZ. Do not sort, deduplicate, strip, or case-normalize dependencies. +3. Add a pure `services` Collector module and fake-source tests. + - Add `WindowsServicesCollector`, a stable descriptor, `ServiceDataSource`, raw UTF-16/config records, bounded collection, identity grouping, deterministic sorting, diagnostics, and aggregate outcome assembly. + - Use one scope, `current_token.win32`, to name the actual visibility boundary. + - Use atomic per-service evidence: base config, description, and delayed-auto-start must all query and strictly decode successfully before emitting an observation. Any query denial/failure/malformed/over-limit result omits that service, marks the scope partial, emits an item diagnostic, and retains complete siblings. Consequently `None` means known configured absence, never unreadable. + - Even a successful real enumeration remains `partial` with one stable `service_visibility_best_effort` diagnostic because API success cannot prove exhaustive visibility. Synthetic tests may explicitly use complete coverage only to validate generic Diff Removed semantics. +4. Add the Win32 SCM adapter behind `cfg(windows)`. + - Add only the `Win32_System_Services` windows-rs feature needed by these APIs. + - Own SCM and service handles in private non-Copy RAII wrappers; close each successful open exactly once. Borrow no handle beyond its valid owner. + - Enumerate with `SC_ENUM_PROCESS_INFO`, `SERVICE_WIN32`, and `SERVICE_STATE_ALL`. Treat returned PID/state/checkpoint/wait-hint as transient plumbing and never retain them. + - Implement aligned, initialized native buffers; validate struct arrays, pointer ranges/alignment, byte counts, termination, checked conversions, pagination resume progress, and returned service counts before copying evidence. + - `EnumServicesStatusExW` pages are capped at the documented 256 KiB API maximum. Retain entries returned with `ERROR_MORE_DATA`, reuse the returned resume handle, and require bounded forward progress. + - Use the documented probe/read pattern for `QueryServiceConfigW` and the two approved `QueryServiceConfig2W` levels only: `SERVICE_CONFIG_DESCRIPTION` and `SERVICE_CONFIG_DELAYED_AUTO_START_INFO`. Each query buffer is capped at the documented 8 KiB API maximum. + - Read a complete configuration bundle until two consecutive reads agree, using at most three complete reads. A vanishing or continuously changing service produces a deterministic item diagnostic; the Collector does not claim an atomic system Snapshot. +5. Apply SystemDiff resource budgets. + - At most 4,096 enumerated services and 64 pages/progress steps. + - At most 32 KiB of retained UTF-16/text evidence per service and 16 MiB across the Collector. + - The 256 KiB enumeration and 8 KiB query ceilings follow the documented APIs; count/evidence budgets are SystemDiff capture limits, not Windows platform limits. + - Select retained observations deterministically by canonical identity before applying the aggregate budget. An over-limit item is omitted with partial coverage; complete siblings remain. +6. Integrate orchestration and reporting. + - Mark Services implemented, run Registry and Services independently, and assemble both outcomes deterministically. + - Add dedicated human service rendering with friendly known start-type labels, exact field-level Modified output, calm Inconclusive text, and hostile terminal-string escaping. + - Expand technical output to every wire field, raw numeric values, identity/scope/version, coverage, and native diagnostics. Unknown numeric values remain numeric and never panic. + - Keep Diff production semantics and JSON schema unchanged apart from the deliberate embedded Service artifact fields. +7. Add focused synthetic fixtures and cross-platform tests. + - Cover Added under synthetic complete coverage, field-level Modified (start type, binary, description-only), Removed under synthetic complete coverage, and appearance/disappearance under partial coverage as Inconclusive. + - Cover access denied, vanishing/query-changing service, sibling preservation, invalid UTF-16/MULTI_SZ, empty/multiple/group dependencies, unknown raw constants, per-user suffixes, driver filtering, pagination/growth/invalid native pointers, limits, identity collisions, ordering, control/bidi escaping, and deterministic JSON. +8. Update portable verification and documentation. + - Require both implemented Collector lines and parse the generated Snapshot for Registry and Services runs rather than matching a singleton Registry array. + - Retain artifact-only execution, exact archive allowlists, checksum, static CRT, `asInvoker`, unsigned state, and import allowlist. Measure the actual final PE before changing any import expectation. + - Remove the obsolete Issue #9 branch trigger from CI; feature PRs still run Windows/Ubuntu Rust gates, while the exact Issue #11 branch gets a narrow, visibly named candidate artifact for downloaded-binary verification. + - Update README EN/ZH, Quickstart, Collector/data-format/architecture/threat-model/roadmap docs, project state, and privacy wording without broad marketing rewrites. +9. Validate locally and on a real Windows host. + - Run format, locked Clippy, all workspace tests, three service-fixture report modes, `collectors`, package/verifier checks, Markdown/parity/stale/secret scans, and `git diff --check`. + - Run two short-window, read-only real Snapshots. Report only counts/status/diagnostic summaries and non-sensitive generic facts; never upload or commit the real inventory. Delete exact temporary files. + - Confirm both Collectors exist, at least one service is normally observed when the host allows it, no driver-only artifact is emitted, ordering is stable, and no obvious spurious configuration modifications occur. Do not make zero changes a flaky invariant. +10. Request an independent reviewer focused on unsafe/pointer correctness, access rights, coverage/no-false-removal, evidence/identity/unknown semantics, resource limits, privacy, read-only boundaries, rendering, and portable integration. Resolve every High/Medium finding and actionable Low issue. +11. Commit, push `feat/windows-services`, create a ready PR closing Issue #11, and wait for required Windows/Ubuntu CI plus the applicable portable candidate/download verification. Stop with the PR unmerged. + +## Affected files and modules + +- `.agent/plans/windows-services-collector.md` +- `.agent/PROJECT_STATE.md` +- `.github/workflows/ci.yml` only for stale candidate trigger and directly relevant validation +- `crates/systemdiff-core/src/lib.rs` +- `crates/systemdiff-core/tests/` +- `crates/systemdiff-windows/Cargo.toml` +- `crates/systemdiff-windows/src/lib.rs` +- new `crates/systemdiff-windows/src/services.rs` +- new or focused native adapter code under `crates/systemdiff-windows/src/` +- `crates/systemdiff-diff/tests/` +- `crates/systemdiff-report/src/lib.rs` and tests +- `crates/systemdiff-cli/src/main.rs` and tests +- focused synthetic service fixtures under `fixtures/` +- `scripts/verify-windows-preview.ps1` +- `README.md`, `README.zh-CN.md`, `packaging/windows/QUICKSTART.md` +- relevant `docs/` architecture, data-format, Collector, threat-model, and roadmap files + +## Test strategy + +- Pure core tests validate the new required wire fields, known absence, service evidence invariants, identity, artifact association, round-trip, and deliberate rejection of old draft Service shapes. +- Pure Collector tests use a fake source for order independence, exact UTF-16 identity, per-user names, dependency semantics, atomic observation policy, access/error mapping, mutation, collisions, and deterministic budgets. +- Native state-machine tests inject enumeration/query calls to cover `ERROR_MORE_DATA`, `ERROR_INSUFFICIENT_BUFFER`, pagination/resume, zero progress, returned counts, pointer/alignment/range checks, termination, and caps without writing SCM state. +- Diff tests cover every change kind and ensure partial/denied/missing Services coverage cannot confirm a one-sided absence or appearance. +- Report tests cover Added/Modified/Removed/Inconclusive, exact changed fields, all technical fields, unknown constants, and control/bidi escaping. +- Windows-only integration captures real read-only evidence and reopens/validates it; default CI never writes services or needs elevation. +- Portable verification executes only the packaged/downloaded executable, requires both Collectors, parses a real read-only Snapshot, and preserves binary/package security gates. + +Required commands include: + +```text +cargo fmt --all --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace --all-targets +``` + +## Risks + +- SCM silently omits inaccessible status objects. Mitigation: real scope is always partial/best-effort, so missing evidence is never confirmed Removed. +- An optional field could confuse query failure with absence. Mitigation: emit only an atomically complete selected configuration bundle. +- Native buffers contain interior pointers. Mitigation: aligned initialized storage, checked pointer/range/termination validation, copy before buffer release, pure state-machine tests, and narrow unsafe review. +- Service configuration can mutate between calls. Mitigation: bounded consecutive-equality reads, diagnostics, no atomicity claim. +- Case-insensitive SCM identity lacks a documented durable independent canonical token. Mitigation: exact evidence hash, explicit false-split limitation, versioned future correction. +- Service evidence can increase Snapshot size and disclose accounts/paths/descriptions. Mitigation: retained-evidence budgets, 64 MiB output cap, unredacted warning, no real inventory in fixtures/logs/artifacts. +- A permanent partial scope means real one-sided Service changes are conservative Inconclusive. This is intentional until Windows exposes or SystemDiff can justify a stronger visibility contract. + +## Rollback and compatibility + +The Collector and renderer can be reverted without writing system state. The wire additions are a deliberate pre-v0.1 correction: committed draft Service fixtures are migrated, and older draft Service documents missing `load_order_group`/`tag_id` are rejected. Snapshot document schema remains v1 because no public stable v0.1 schema has shipped. The Registry Collector and existing Registry-only report behavior remain unchanged. + +## Progress + +- [x] 2026-08-13: synchronized clean `main` and `origin/main` at `fa2216427e0d1da2972916c46495978c9a050d0f`; latest main CI and portable verification were green. +- [x] 2026-08-13: confirmed no open PR/Issue and created Issue #11 plus branch `feat/windows-services`. +- [x] 2026-08-13: mapped the existing core/diff/report/CLI/portable boundaries and baseline fixture gaps. +- [x] 2026-08-13: completed initial official API and test-strategy research; baseline format, Clippy, and 88 workspace tests passed on the untouched branch. +- [x] 2026-08-13: implemented the draft Service wire correction, validation, identity, and focused core tests. +- [x] 2026-08-13: implemented the pure Collector and query-only native SCM adapter with RAII handles, bounded native buffers/pagination, strict UTF-16, atomic observations, and deterministic selection. +- [x] 2026-08-13: integrated both Collectors into Snapshot capture, Service report rendering, focused fixtures/tests, portable verification, and public/internal docs. +- [x] 2026-08-13: completed local, real-Windows, portable, and independent-review validation; all review findings were resolved. +- [ ] Commit, push, open PR, and observe final remote CI. + +## Discoveries + +- `EnumServicesStatusExW` silently omits a service when the caller lacks `SERVICE_QUERY_STATUS`; API success is therefore not exhaustive-visibility evidence. +- `EnumServicesStatusExW` documents a 256 KiB maximum output array and resume semantics with entries potentially returned alongside `ERROR_MORE_DATA`. +- `QueryServiceConfigW` and `QueryServiceConfig2W` require `SERVICE_QUERY_CONFIG`, use documented size probes, and document an 8 KiB maximum output buffer. +- Service names are limited to 256 characters, preserve case, compare case-insensitively, and reject slash/backslash. Display names also preserve case and compare case-insensitively. +- Per-user services expose full `_LUID`-suffixed service/display names and a native per-user service-type modifier; collection must not merge or strip those instances. +- `QUERY_SERVICE_CONFIGW.lpDependencies` is a double-NUL-terminated list of service/group names; group dependencies retain the documented `+` prefix. +- Real read-only validation found Windows service configurations where the delayed-auto-start flag remained true while the current start type was not Automatic. The wire therefore preserves both raw facts independently instead of enforcing a false cross-field invariant. + +## Decisions + +- Use scope `current_token.win32` and permanently conservative partial coverage for the real v1 adapter. +- Use atomic selected-field observations instead of a generalized per-field acquisition-state framework. +- Add load-order group and tag now, while the schema is still draft, rather than contradict the public Collector contract. +- Preserve exact dependency sequence and evidence; do not invent normalization unsupported by Microsoft. +- Use exact UTF-16 identity with a versioned domain and document the case-only limitation rather than couple Snapshot comparison to mutable/undocumented Unicode or NLS folding. +- Retain unknown raw numeric configuration values for forward compatibility; validate only invariants required to prove the artifact is a Win32 non-driver service. +- Treat successful absence as `None` only after all three selected queries succeed; any base/description/delayed query failure omits the item and retains partial coverage. + +## Final validation + +Local implementation validation on 2026-08-13: + +- `cargo fmt --all --check`: passed. +- `cargo clippy --locked --workspace --all-targets -- -D warnings`: passed. +- `cargo test --locked --workspace --all-targets`: passed, 113 tests, 0 failures. +- Focused Services fixtures ran through human, technical, and JSON CLI modes and produced one factual Added service with complete technical evidence. +- Two back-to-back real read-only Snapshots each retained 341 Services observations; the default Diff reported no confirmed changes and JSON contained no changes. Both temporary Snapshots were deleted exactly. No service was created, modified, started, stopped, or deleted. +- Real coverage was `partial` with `service_visibility_best_effort`; driver-only observations were 0. Item diagnostics showed that selected queries can fail or return evidence outside the accepted contract without aborting siblings. +- Final local portable package verification passed after removing Cargo/Rust/linker tools from `PATH`: AMD64, `asInvoker`, `uiAccess=false`, unsigned, no delayed imports, reviewed imports `advapi32.dll`, `api-ms-win-core-synch-l1-2-0.dll`, `KERNEL32.dll`, and `ntdll.dll`. EXE size was 1,860,608 bytes; ZIP size was 740,539 bytes. Exact outer/inner allowlists and checksum passed, and the packaged binary captured both Collectors. +- `git diff --check`: passed. Repository secret/machine-path scan found no new credential or committed real-host evidence. +- Independent reviewer final result: High 0 / Medium 0 / Low 0. The review-driven fixes made per-service failure diagnostics distinguishable without exposing service names and rendered delayed-auto-start changes independently of start type. +- Final remote CI/artifact results remain pending and will be appended without inference. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 095a5e4..c920fa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,8 @@ on: push: branches: - main - # Narrow pre-merge candidate validation for Issue #9. - - build/portable-developer-preview + # Narrow pre-merge candidate validation for Issue #11. + - feat/windows-services pull_request: permissions: diff --git a/Cargo.lock b/Cargo.lock index f2c8dff..2f6768a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,6 +323,7 @@ version = "0.0.0" dependencies = [ "serde", "serde_json", + "sha2", "time", ] diff --git a/README.md b/README.md index c81d000..aa2b224 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ **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?” +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 startup entries or Windows services did it change?” > [!IMPORTANT] -> SystemDiff is pre-release software. Today it captures and compares the documented Windows Registry Run/RunOnce startup locations. An unsigned, short-lived Windows x64 Developer Preview is available from eligible CI runs, but there is no official binary Release. Services, Scheduled Tasks, rules, redaction, releases, and the desktop app are not implemented. +> SystemDiff is pre-release software. Today it captures and compares the documented Windows Registry Run/RunOnce startup locations and Windows service configuration visible to the current token. An unsigned, short-lived Windows x64 Developer Preview is available from eligible CI runs, but there is no official binary Release. Scheduled Tasks, rules, redaction, releases, and the desktop app are not implemented. [Try the sample](#try-the-registry-demo) · [Developer Preview builds](#developer-preview-builds) · [Build from source](#build-from-source) · [Inspect the data format](docs/data-format.md) @@ -26,7 +26,7 @@ _Verified output from the committed synthetic Registry-only fixtures. No real ho | 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 | +| Capture current-token-visible Windows service configuration (drivers excluded) | Implemented with conservative partial coverage | | Scheduled Tasks Collector | Planned; not implemented | | Rules, signatures, risk classification, and redacted sharing | Planned; not implemented | @@ -68,7 +68,7 @@ systemdiff diff --technical before.json after.json systemdiff diff --json before.json after.json ``` -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. +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 and service configuration evidence, raw numeric values, and coverage diagnostics. `--json` preserves the language-neutral Diff schema. ## Capture a real before/after pair @@ -81,7 +81,7 @@ systemdiff snapshot -o after.json systemdiff diff before.json after.json ``` -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. +This workflow currently covers Registry Run/RunOnce evidence and Windows service configuration. Service visibility depends on the current token and object ACLs, so Services v1 conservatively marks its scope partial: a missing service becomes Inconclusive rather than a confirmed removal. 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 contain service accounts, paths and arguments, descriptions, command strings, usernames, hashes, and other host details. Review every report before sharing, and never attach unreviewed real evidence to a public Issue. 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. @@ -117,7 +117,7 @@ There is no official binary Release yet. The CI Developer Preview above is unsig 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. -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. +Registry startup and Windows Services are the first two completed vertical slices, not the finished v0.1. See the [Collector notes](docs/collectors.md) and [roadmap](docs/roadmap.md) for current boundaries. ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 446a1ee..496cc3c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,10 +8,10 @@ **离线优先 · 只读 · 无需账号 · 不含遥测** -SystemDiff 会分别创建变更前后的 Snapshot,再说明两者之间有哪些证据发生了变化。它要回答的是这类问题:“我刚安装了这个程序,它往启动项里加了什么?” +SystemDiff 会分别创建变更前后的 Snapshot,再说明两者之间有哪些证据发生了变化。它要回答的是这类问题:“我刚安装了这个程序,它改了哪些启动项或 Windows 服务?” > [!IMPORTANT] -> SystemDiff 仍处于预发布阶段。现阶段真正支持的是采集并比较 Windows Registry 中有官方文档的 Run/RunOnce 启动项。符合条件的 CI run 会提供短期有效、未签名的 Windows x64 Developer Preview,但目前没有正式二进制 Release。Windows 服务、计划任务、规则、脱敏、正式发布包和桌面应用均尚未实现。 +> SystemDiff 仍处于预发布阶段。现阶段真正支持的是采集并比较 Windows Registry 中有官方文档的 Run/RunOnce 启动项,以及当前 token 可见的 Windows 服务配置。符合条件的 CI run 会提供短期有效、未签名的 Windows x64 Developer Preview,但目前没有正式二进制 Release。计划任务、规则、脱敏、正式发布包和桌面应用均尚未实现。 [试用示例](#试用-registry-示例) · [Developer Preview 构建](#developer-preview-构建) · [从源码构建](#从源码构建) · [查看数据格式](docs/data-format.md) @@ -26,7 +26,7 @@ _图中是仓库内 Registry-only synthetic fixtures 生成并经过验证的真 | 采集当前用户和本机范围内的 Run/RunOnce 证据 | 已在受支持的 Windows 系统上实现 | | 易读文本、technical 文本和确定性 JSON 三种 Diff 输出 | 已实现 | | 感知采集覆盖情况,不把缺失证据误报为删除 | 已实现 | -| Windows Services Collector | 计划作为下一个 Collector;尚未实现 | +| 采集当前 token 可见的 Windows 服务配置(不含驱动) | 已实现,并采用保守的 partial coverage | | Scheduled Tasks Collector | 计划中;尚未实现 | | 规则、数字签名、风险判断和脱敏分享 | 计划中;尚未实现 | @@ -68,7 +68,7 @@ systemdiff diff --technical before.json after.json systemdiff diff --json before.json after.json ``` -默认输出不使用颜色或 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。 +默认输出不使用颜色或 ANSI 格式,因此重定向到文件或管道后仍能看懂。`--technical` 会显示 Collector version、scope、canonical identity、Registry 和服务配置证据、raw numeric values 以及 coverage diagnostics。`--json` 保持语言无关的 Diff schema。 ## 采集真实的 before/after Snapshot @@ -81,7 +81,7 @@ systemdiff snapshot -o after.json systemdiff diff before.json after.json ``` -当前流程只覆盖 Registry Run/RunOnce 证据。两份 Snapshot 必须来自同一套 Windows 安装、同一用户/主体上下文。Snapshot 和所有 Diff/report 模式均未脱敏:易读文本、technical 文本和 JSON 都可能包含命令字符串、路径中的用户名、hash、raw evidence 和其他主机信息。分享前务必检查每一份报告,绝不要把未经检查的真实证据附到公开 Issue 中。 +当前流程覆盖 Registry Run/RunOnce 证据和 Windows 服务配置。服务可见性取决于当前 token 和对象 ACL,因此 Services v1 会保守地把 scope 标记为 partial:缺失的服务会显示为 Inconclusive,而不是确认已删除。两份 Snapshot 必须来自同一套 Windows 安装、同一用户/主体上下文。Snapshot 和所有 Diff/report 模式均未脱敏:易读文本、technical 文本和 JSON 都可能包含服务账号、路径与参数、描述、命令字符串、用户名、hash 和其他主机信息。分享前务必检查每一份报告,绝不要把未经检查的真实证据附到公开 Issue 中。 当前最低支持 Windows 10 version 1709 或 Windows Server 2016 version 1709。ARM64 可以采集当前用户的 Shared Registry scopes;在能够正确表达并测试相关 view semantics 之前,Collector v1 会明确把 HKLM alternate-view coverage 标记为 unsupported。 @@ -117,7 +117,7 @@ cargo run --locked -p systemdiff-cli -- collectors Rust workspace 把带版本的领域数据、Windows API 访问、确定性 Diff、规则、报告和 CLI 组合彼此分离。未来的桌面客户端计划复用同一套 core;目前尚未生成 Tauri 应用。 -Registry startup 是第一个完成的 vertical slice,并不代表 v0.1 已经完成。当前边界和后续计划见 [Collector 说明](docs/collectors.md)与[路线图](docs/roadmap.md)。 +Registry startup 和 Windows Services 是前两个完成的 vertical slice,并不代表 v0.1 已经完成。当前边界和后续计划见 [Collector 说明](docs/collectors.md)与[路线图](docs/roadmap.md)。 ## 参与贡献 diff --git a/crates/systemdiff-cli/src/main.rs b/crates/systemdiff-cli/src/main.rs index 33a53af..f2fe40a 100644 --- a/crates/systemdiff-cli/src/main.rs +++ b/crates/systemdiff-cli/src/main.rs @@ -400,7 +400,7 @@ mod tests { #[cfg(windows)] #[test] - fn real_read_only_snapshot_serializes_and_reopens_with_explicit_registry_scopes() { + fn real_read_only_snapshot_serializes_and_reopens_with_both_collectors() { let captured_at = "2026-08-11T00:00:00Z"; let snapshot = capture_snapshot(captured_at.to_owned(), "0.0.0-test".to_owned()) .expect("supported Windows test host must produce a read-only Snapshot"); @@ -410,7 +410,10 @@ mod tests { .expect("generated Snapshot must reopen through header-first routing"); assert_eq!(reparsed.captured_at, captured_at); - assert_eq!(reparsed.enabled_collectors, ["windows.registry.startup"]); + assert_eq!( + reparsed.enabled_collectors, + ["windows.registry.startup", "windows.services"] + ); let registry = reparsed .collectors .iter() @@ -431,6 +434,23 @@ mod tests { .iter() .any(|coverage| coverage.scope_id == "current_user.shared.run_once") ); + let services = reparsed + .collectors + .iter() + .find(|run| run.id == "windows.services") + .expect("Windows Services Collector run must exist"); + assert_eq!(services.status, systemdiff_core::CollectorStatus::Partial); + assert!(services.coverage.iter().any(|coverage| { + coverage.scope_id == "current_token.win32" + && coverage.status == systemdiff_core::CollectorStatus::Partial + })); + assert!(reparsed.observations.iter().any(|observation| { + observation.collector_id == "windows.services" + && matches!( + observation.artifact, + systemdiff_core::Artifact::WindowsService(_) + ) + })); } #[test] diff --git a/crates/systemdiff-core/Cargo.toml b/crates/systemdiff-core/Cargo.toml index 857e49d..cffbd6b 100644 --- a/crates/systemdiff-core/Cargo.toml +++ b/crates/systemdiff-core/Cargo.toml @@ -9,4 +9,5 @@ publish = false [dependencies] serde.workspace = true serde_json.workspace = true +sha2.workspace = true time.workspace = true diff --git a/crates/systemdiff-core/src/lib.rs b/crates/systemdiff-core/src/lib.rs index e3f648f..2315006 100644 --- a/crates/systemdiff-core/src/lib.rs +++ b/crates/systemdiff-core/src/lib.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fmt; @@ -11,6 +12,13 @@ pub const SNAPSHOT_DOCUMENT_TYPE: &str = "systemdiff.snapshot"; pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; pub const REGISTRY_RAW_EVIDENCE_MAX_CAPTURE_BYTES: u64 = 4_096; pub const REGISTRY_VALUE_NAME_MAX_UTF16_UNITS: usize = 16_383; +pub const WINDOWS_SERVICES_COLLECTOR_ID: &str = "windows.services"; +pub const WINDOWS_SERVICES_COLLECTOR_VERSION: u32 = 1; +pub const WINDOWS_SERVICES_SCOPE_ID: &str = "current_token.win32"; +pub const WINDOWS_SERVICE_NAME_MAX_UTF16_UNITS: usize = 256; +pub const WINDOWS_SERVICE_EVIDENCE_MAX_UTF16_UNITS: usize = 16_384; + +const WINDOWS_SERVICE_IDENTITY_DOMAIN: &[u8] = b"systemdiff.windows-services.identity.v1\0"; #[derive(Debug, Deserialize)] struct SnapshotDocumentHeader { @@ -232,8 +240,12 @@ impl Snapshot { "observations[].canonical_id", )); } - if let Artifact::RegistryStartup(entry) = &observation.artifact { - validate_registry_startup_entry(entry)?; + match &observation.artifact { + Artifact::RegistryStartup(entry) => validate_registry_startup_entry(entry)?, + Artifact::WindowsService(service) => { + validate_windows_service_observation(observation, service)?; + } + Artifact::ScheduledTask(_) => {} } let key = observation.key(); @@ -717,10 +729,139 @@ pub struct WindowsService { pub binary_path: String, pub account: Option, pub dependencies: Vec, - pub delayed_auto_start: Option, + #[serde(deserialize_with = "deserialize_required_option")] + pub load_order_group: Option, + #[serde(deserialize_with = "deserialize_required_option")] + pub tag_id: Option, + pub delayed_auto_start: bool, pub description: Option, } +fn deserialize_required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +/// Returns the versioned Collector v1 identity for exact service-name UTF-16 +/// code units. SCM name comparisons are case-insensitive, but Windows does not +/// expose a documented persistent canonical token suitable for independent, +/// cross-platform Snapshot generation. Collector v1 therefore preserves exact +/// returned casing and documents the possible casing-only false split. +pub fn windows_service_identity(service_name_utf16: &[u16]) -> String { + let unit_count = u32::try_from(service_name_utf16.len()).unwrap_or(u32::MAX); + let mut digest = Sha256::new(); + digest.update(WINDOWS_SERVICE_IDENTITY_DOMAIN); + digest.update(unit_count.to_le_bytes()); + for unit in service_name_utf16 { + digest.update(unit.to_le_bytes()); + } + lower_hex_bytes(&digest.finalize()) +} + +fn lower_hex_bytes(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + +fn validate_windows_service_observation( + observation: &Observation, + service: &WindowsService, +) -> Result<(), SnapshotValidationError> { + if observation.collector_id != WINDOWS_SERVICES_COLLECTOR_ID + || observation.collector_version != WINDOWS_SERVICES_COLLECTOR_VERSION + || observation.scope_id != WINDOWS_SERVICES_SCOPE_ID + { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "collector/scope", + }); + } + + let service_name_units: Vec = service.service_name.encode_utf16().collect(); + if service_name_units.is_empty() + || service_name_units.len() > WINDOWS_SERVICE_NAME_MAX_UTF16_UNITS + || service.service_name.contains(['\0', '/', '\\']) + { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "service_name", + }); + } + if observation.canonical_id != windows_service_identity(&service_name_units) { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "canonical_id/service_name", + }); + } + + let win32_base = service.service_type & 0x30; + let driver_bits = service.service_type & 0x0f; + if !matches!(win32_base, 0x10 | 0x20) || driver_bits != 0 { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "service_type", + }); + } + if service.binary_path.is_empty() || service.binary_path.contains('\0') { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "binary_path", + }); + } + + let optional_nonempty = [ + ("display_name", service.display_name.as_deref()), + ("account", service.account.as_deref()), + ("load_order_group", service.load_order_group.as_deref()), + ("description", service.description.as_deref()), + ]; + for (field, value) in optional_nonempty { + if value.is_some_and(|value| value.is_empty() || value.contains('\0')) { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { field }); + } + } + if service + .dependencies + .iter() + .any(|dependency| dependency.is_empty() || dependency.contains('\0')) + { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "dependencies", + }); + } + + let evidence_units = service_name_units + .len() + .checked_add(service.display_name.as_deref().map_or(0, utf16_len)) + .and_then(|total| total.checked_add(utf16_len(&service.binary_path))) + .and_then(|total| total.checked_add(service.account.as_deref().map_or(0, utf16_len))) + .and_then(|total| { + service + .dependencies + .iter() + .try_fold(total, |total, dependency| { + total.checked_add(utf16_len(dependency)) + }) + }) + .and_then(|total| { + total.checked_add(service.load_order_group.as_deref().map_or(0, utf16_len)) + }) + .and_then(|total| total.checked_add(service.description.as_deref().map_or(0, utf16_len))); + if !evidence_units.is_some_and(|units| units <= WINDOWS_SERVICE_EVIDENCE_MAX_UTF16_UNITS) { + return Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "retained_text_evidence", + }); + } + Ok(()) +} + +fn utf16_len(value: &str) -> usize { + value.encode_utf16().count() +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScheduledTask { pub task_path: String, @@ -941,6 +1082,9 @@ pub enum SnapshotValidationError { InvalidRegistryEvidence { field: &'static str, }, + InvalidWindowsServiceEvidence { + field: &'static str, + }, DuplicateObservation(ArtifactKey), } @@ -1032,6 +1176,9 @@ impl fmt::Display for SnapshotValidationError { Self::InvalidRegistryEvidence { field } => { write!(formatter, "invalid Registry evidence: {field}") } + Self::InvalidWindowsServiceEvidence { field } => { + write!(formatter, "invalid Windows service evidence: {field}") + } Self::DuplicateObservation(key) => { write!(formatter, "duplicate observation identity: {key}") } diff --git a/crates/systemdiff-core/tests/snapshot_fixture.rs b/crates/systemdiff-core/tests/snapshot_fixture.rs index 80d723d..e837278 100644 --- a/crates/systemdiff-core/tests/snapshot_fixture.rs +++ b/crates/systemdiff-core/tests/snapshot_fixture.rs @@ -301,6 +301,7 @@ fn failed_aggregate_cannot_claim_complete_scope_coverage() { .find(|run| run.id == "windows.services") .expect("services run must exist"); services.status = CollectorStatus::Failed; + services.coverage[0].status = CollectorStatus::Complete; assert!(matches!( snapshot.validate(), diff --git a/crates/systemdiff-core/tests/windows_services.rs b/crates/systemdiff-core/tests/windows_services.rs new file mode 100644 index 0000000..116d682 --- /dev/null +++ b/crates/systemdiff-core/tests/windows_services.rs @@ -0,0 +1,136 @@ +use systemdiff_core::{ + Artifact, CollectorStatus, Snapshot, SnapshotValidationError, WindowsService, + windows_service_identity, +}; + +fn before_snapshot() -> Snapshot { + serde_json::from_str(include_str!("../../../fixtures/snapshots/before-v1.json")) + .expect("the before fixture must deserialize") +} + +fn service_observation(snapshot: &mut Snapshot) -> &mut systemdiff_core::Observation { + snapshot + .observations + .iter_mut() + .find(|observation| matches!(observation.artifact, Artifact::WindowsService(_))) + .expect("fixture must contain service evidence") +} + +fn service(snapshot: &mut Snapshot) -> &mut WindowsService { + let Artifact::WindowsService(service) = &mut service_observation(snapshot).artifact else { + unreachable!() + }; + service +} + +#[test] +fn service_identity_has_fixed_exact_utf16_vectors() { + assert_eq!( + windows_service_identity(&"LegacyService".encode_utf16().collect::>()), + "46561d2f6078ffa19958353b4dd219719a0316486a319e9b30f5da83feee9a94" + ); + assert_eq!( + windows_service_identity( + &"ExampleUpdaterService_1a2b3" + .encode_utf16() + .collect::>() + ), + "1f8fb1c080b80b63fc87e55671c51dda7a3c16cf4a3c7dcbe7224c75ef1000c8" + ); + assert_ne!( + windows_service_identity(&"ExampleService".encode_utf16().collect::>()), + windows_service_identity(&"exampleservice".encode_utf16().collect::>()) + ); +} + +#[test] +fn service_fixture_round_trips_known_absence_and_false_delayed_start() { + let snapshot = before_snapshot(); + snapshot.validate().expect("fixture must validate"); + let json = serde_json::to_string_pretty(&snapshot).expect("snapshot must serialize"); + let reparsed: Snapshot = serde_json::from_str(&json).expect("snapshot must deserialize"); + assert_eq!(reparsed, snapshot); + + let entry = reparsed + .observations + .iter() + .find_map(|observation| match &observation.artifact { + Artifact::WindowsService(service) => Some(service), + _ => None, + }) + .expect("fixture must contain service evidence"); + assert_eq!(entry.load_order_group, None); + assert_eq!(entry.tag_id, None); + assert!(!entry.delayed_auto_start); +} + +#[test] +fn old_draft_service_wire_without_new_required_fields_is_rejected() { + let json = include_str!("../../../fixtures/snapshots/before-v1.json") + .replace(" \"load_order_group\": null,\n", "") + .replace(" \"tag_id\": null,\n", ""); + assert!(serde_json::from_str::(&json).is_err()); +} + +#[test] +fn invalid_service_identity_and_evidence_are_rejected() { + let mut invalid = before_snapshot(); + service_observation(&mut invalid).canonical_id = "wrong".to_owned(); + assert!(matches!( + invalid.validate(), + Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "canonical_id/service_name" + }) + )); + + let mut invalid = before_snapshot(); + service(&mut invalid).service_name.clear(); + assert!(matches!( + invalid.validate(), + Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "service_name" + }) + )); + + let mut invalid = before_snapshot(); + service(&mut invalid).service_type = 1; + assert!(matches!( + invalid.validate(), + Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "service_type" + }) + )); + + let mut invalid = before_snapshot(); + service(&mut invalid).dependencies.push(String::new()); + assert!(matches!( + invalid.validate(), + Err(SnapshotValidationError::InvalidWindowsServiceEvidence { + field: "dependencies" + }) + )); +} + +#[test] +fn service_artifact_must_use_the_services_collector_and_scope() { + let mut invalid = before_snapshot(); + service_observation(&mut invalid).scope_id = "machine.win32".to_owned(); + assert!(matches!( + invalid.validate(), + Err(SnapshotValidationError::MissingCoverage { .. }) + | Err(SnapshotValidationError::InvalidWindowsServiceEvidence { .. }) + )); +} + +#[test] +fn real_services_scope_is_represented_as_partial() { + let snapshot = before_snapshot(); + let run = snapshot + .collectors + .iter() + .find(|run| run.id == "windows.services") + .expect("services run must exist"); + assert_eq!(run.status, CollectorStatus::Partial); + assert_eq!(run.coverage[0].status, CollectorStatus::Partial); + assert_eq!(run.coverage[0].scope_id, "current_token.win32"); +} diff --git a/crates/systemdiff-diff/tests/snapshot_diff.rs b/crates/systemdiff-diff/tests/snapshot_diff.rs index b199c94..880f83a 100644 --- a/crates/systemdiff-diff/tests/snapshot_diff.rs +++ b/crates/systemdiff-diff/tests/snapshot_diff.rs @@ -22,7 +22,7 @@ fn registry_snapshots() -> (Snapshot, Snapshot) { } #[test] -fn classifies_added_removed_modified_and_inconclusive_changes() { +fn broad_fixture_classifies_confirmed_and_inconclusive_changes() { let (before, after) = snapshots(); let diff = diff_snapshots(&before, &after, DiffOptions::default()) .expect("synthetic snapshots must be comparable"); @@ -32,11 +32,6 @@ fn classifies_added_removed_modified_and_inconclusive_changes() { .iter() .any(|change| matches!(&change.change, ChangeKind::Added { .. })) ); - assert!( - diff.changes - .iter() - .any(|change| matches!(&change.change, ChangeKind::Removed { .. })) - ); assert!( diff.changes .iter() @@ -46,8 +41,100 @@ fn classifies_added_removed_modified_and_inconclusive_changes() { change.key.collector_id == "windows.scheduled_tasks" && matches!(&change.change, ChangeKind::Inconclusive { .. }) })); - assert_eq!(diff.warnings.len(), 1); - assert_eq!(diff.warnings[0].collector_id, "windows.scheduled_tasks"); + assert_eq!(diff.warnings.len(), 2); + assert!( + diff.warnings + .iter() + .any(|warning| warning.collector_id == "windows.services") + ); + assert!( + diff.warnings + .iter() + .any(|warning| warning.collector_id == "windows.scheduled_tasks") + ); +} + +fn service_fixture_snapshot(path: &str) -> Snapshot { + let text = match path { + "before" => include_str!("../../../fixtures/snapshots/services-added-before-v1.json"), + "after" => include_str!("../../../fixtures/snapshots/services-added-after-v1.json"), + _ => unreachable!(), + }; + serde_json::from_str(text).expect("focused Services fixture must deserialize") +} + +#[test] +fn complete_service_scope_confirms_added_and_removed() { + let before = service_fixture_snapshot("before"); + let after = service_fixture_snapshot("after"); + let added = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("complete service fixture must compare"); + assert_eq!(added.changes.len(), 1); + assert!(matches!(added.changes[0].change, ChangeKind::Added { .. })); + + let removed = diff_snapshots(&after, &before, DiffOptions::default()) + .expect("reversed complete service fixture must compare"); + assert_eq!(removed.changes.len(), 1); + assert!(matches!( + removed.changes[0].change, + ChangeKind::Removed { .. } + )); +} + +#[test] +fn directly_observed_service_fields_compare_as_modified_under_partial_coverage() { + let mut before = service_fixture_snapshot("after"); + let mut after = before.clone(); + for snapshot in [&mut before, &mut after] { + snapshot.collectors[0].status = CollectorStatus::Partial; + snapshot.collectors[0].coverage[0].status = CollectorStatus::Partial; + snapshot.collectors[0] + .diagnostics + .push(systemdiff_core::Diagnostic { + code: "service_visibility_best_effort".to_owned(), + message: "Synthetic current-token coverage is incomplete.".to_owned(), + stage: Some("enumerate".to_owned()), + native_code: None, + scope_id: Some("current_token.win32".to_owned()), + }); + } + let Artifact::WindowsService(service) = &mut after.observations[0].artifact else { + unreachable!() + }; + service.start_type = 2; + service.binary_path = r#"%ProgramFiles%\Example\new-service.exe --service"#.to_owned(); + service.description = Some("Updated description only where stated.".to_owned()); + + let diff = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("direct service evidence must compare"); + assert!(matches!( + diff.changes[0].change, + ChangeKind::Modified { .. } + )); +} + +#[test] +fn partial_service_scope_never_turns_disappearance_into_removed() { + let before = service_fixture_snapshot("after"); + let mut after = service_fixture_snapshot("before"); + after.collectors[0].status = CollectorStatus::Partial; + after.collectors[0].coverage[0].status = CollectorStatus::Partial; + after.collectors[0] + .diagnostics + .push(systemdiff_core::Diagnostic { + code: "service_visibility_best_effort".to_owned(), + message: "Synthetic current-token coverage is incomplete.".to_owned(), + stage: Some("enumerate".to_owned()), + native_code: None, + scope_id: Some("current_token.win32".to_owned()), + }); + + let diff = diff_snapshots(&before, &after, DiffOptions::default()) + .expect("partial service fixture must compare conservatively"); + assert!(matches!( + diff.changes[0].change, + ChangeKind::Inconclusive { .. } + )); } #[test] diff --git a/crates/systemdiff-report/src/lib.rs b/crates/systemdiff-report/src/lib.rs index 3472592..2575e5f 100644 --- a/crates/systemdiff-report/src/lib.rs +++ b/crates/systemdiff-report/src/lib.rs @@ -96,10 +96,28 @@ pub fn render_terminal(diff: &DiffDocument) -> String { } } + let service_changes: Vec<_> = diff + .changes + .iter() + .filter(|item| change_artifact(item).is_some_and(is_windows_service)) + .collect(); + if !service_changes.is_empty() { + let _ = writeln!(output, "\nWindows service changes\n"); + for (index, item) in service_changes.into_iter().enumerate() { + if index > 0 { + let _ = writeln!(output); + } + render_human_service_change(&mut output, item); + } + } + let other_changes: Vec<_> = diff .changes .iter() - .filter(|item| !change_artifact(item).is_some_and(is_registry)) + .filter(|item| { + !change_artifact(item) + .is_some_and(|artifact| is_registry(artifact) || is_windows_service(artifact)) + }) .collect(); if !other_changes.is_empty() { let _ = writeln!(output, "\nOther evidence changes\n"); @@ -336,6 +354,221 @@ fn render_human_registry_change(output: &mut String, item: &ArtifactChange) { } } +fn render_human_service_change(output: &mut String, item: &ArtifactChange) { + match &item.change { + ChangeKind::Added { + after: Artifact::WindowsService(service), + } => { + human_service_heading(output, '+', "Added", service); + render_human_service_summary(output, service); + } + ChangeKind::Removed { + before: Artifact::WindowsService(service), + } => { + human_service_heading(output, '-', "Removed", service); + render_human_service_summary(output, service); + } + ChangeKind::Modified { + before: Artifact::WindowsService(before), + after: Artifact::WindowsService(after), + } => { + human_service_heading(output, '~', "Modified", after); + render_human_service_modifications(output, before, after); + } + ChangeKind::Unchanged { + artifact: Artifact::WindowsService(service), + } => { + human_service_heading(output, '=', "Unchanged", service); + let _ = writeln!( + output, + " No change in the captured service configuration" + ); + } + ChangeKind::Inconclusive { before, after, .. } => { + let service = before.as_ref().or(after.as_ref()).and_then(service_entry); + if let Some(service) = service { + human_service_heading(output, '?', "Inconclusive", service); + let explanation = match (before.is_some(), after.is_some()) { + (true, false) => { + "Current-token service coverage was incomplete in the after Snapshot, so removal could not be confirmed." + } + (false, true) => { + "Current-token service coverage was incomplete in the before Snapshot, so addition could not be confirmed." + } + _ => { + "Current-token service coverage was incomplete, so this change could not be confirmed." + } + }; + let _ = writeln!(output, " {explanation}"); + render_human_service_summary(output, service); + } + } + _ => render_human_fallback(output, item), + } +} + +fn human_service_heading( + output: &mut String, + symbol: char, + change: &str, + service: &systemdiff_core::WindowsService, +) { + let _ = writeln!(output, " {symbol} {}", human_service_name(service)); + let _ = writeln!(output, " {change} (Windows service)"); + let _ = writeln!( + output, + " Service name: {}", + terminal_text(&service.service_name) + ); +} + +fn render_human_service_summary(output: &mut String, service: &systemdiff_core::WindowsService) { + let _ = writeln!( + output, + " Start: {}\n Binary path: {}\n Account: {}", + human_service_start(service.start_type, service.delayed_auto_start), + terminal_text(&service.binary_path), + human_optional_text(service.account.as_deref()) + ); +} + +fn render_human_service_modifications( + output: &mut String, + before: &systemdiff_core::WindowsService, + after: &systemdiff_core::WindowsService, +) { + if before.service_name != after.service_name { + render_human_changed_text( + output, + "Service name", + &before.service_name, + &after.service_name, + ); + } + if before.display_name != after.display_name { + render_human_changed_optional_text( + output, + "Display name", + before.display_name.as_deref(), + after.display_name.as_deref(), + ); + } + if before.service_type != after.service_type { + render_human_changed_value( + output, + "Service type", + before.service_type, + after.service_type, + ); + } + if before.start_type != after.start_type { + render_human_changed_display( + output, + "Start", + &human_service_start(before.start_type, before.delayed_auto_start), + &human_service_start(after.start_type, after.delayed_auto_start), + ); + } + if before.delayed_auto_start != after.delayed_auto_start { + render_human_changed_display( + output, + "Delayed automatic start configured", + human_bool(before.delayed_auto_start), + human_bool(after.delayed_auto_start), + ); + } + if before.error_control != after.error_control { + render_human_changed_display( + output, + "Error control", + &human_error_control(before.error_control), + &human_error_control(after.error_control), + ); + } + if before.binary_path != after.binary_path { + render_human_changed_text( + output, + "Binary path", + &before.binary_path, + &after.binary_path, + ); + } + if before.account != after.account { + render_human_changed_optional_text( + output, + "Account", + before.account.as_deref(), + after.account.as_deref(), + ); + } + if before.dependencies != after.dependencies { + render_human_changed_display( + output, + "Dependencies", + &human_dependencies(&before.dependencies), + &human_dependencies(&after.dependencies), + ); + } + if before.load_order_group != after.load_order_group { + render_human_changed_optional_text( + output, + "Load-order group", + before.load_order_group.as_deref(), + after.load_order_group.as_deref(), + ); + } + if before.tag_id != after.tag_id { + render_human_changed_display( + output, + "Tag ID", + &human_optional_u32(before.tag_id), + &human_optional_u32(after.tag_id), + ); + } + if before.description != after.description { + render_human_changed_optional_text( + output, + "Description", + before.description.as_deref(), + after.description.as_deref(), + ); + } +} + +fn render_human_changed_text(output: &mut String, label: &str, before: &str, after: &str) { + render_human_changed_display(output, label, &terminal_text(before), &terminal_text(after)); +} + +fn render_human_changed_optional_text( + output: &mut String, + label: &str, + before: Option<&str>, + after: Option<&str>, +) { + render_human_changed_display( + output, + label, + &human_optional_text(before), + &human_optional_text(after), + ); +} + +fn render_human_changed_value( + output: &mut String, + label: &str, + before: T, + after: T, +) { + render_human_changed_display(output, label, &before.to_string(), &after.to_string()); +} + +fn render_human_changed_display(output: &mut String, label: &str, before: &str, after: &str) { + let _ = writeln!( + output, + " {label}:\n Before: {before}\n After: {after}" + ); +} + fn human_entry_heading( output: &mut String, symbol: char, @@ -458,17 +691,7 @@ fn render_technical_artifact(output: &mut String, heading: &str, artifact: &Arti 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) - ); + render_technical_service(output, service); } Artifact::ScheduledTask(task) => { let _ = writeln!( @@ -483,6 +706,41 @@ fn render_technical_artifact(output: &mut String, heading: &str, artifact: &Arti } } +fn render_technical_service(output: &mut String, service: &systemdiff_core::WindowsService) { + let _ = writeln!( + output, + " service name: {}\n display name: {}\n service type: {}\n start type: {} ({})\n error control: {} ({})\n binary path: {}\n account: {}", + terminal_text(&service.service_name), + technical_optional_literal(service.display_name.as_deref()), + service.service_type, + service.start_type, + technical_start_type(service.start_type), + service.error_control, + technical_error_control(service.error_control), + terminal_text(&service.binary_path), + technical_optional_literal(service.account.as_deref()) + ); + if service.dependencies.is_empty() { + let _ = writeln!(output, " dependencies: none"); + } else { + let _ = writeln!(output, " dependencies ({}):", service.dependencies.len()); + for (index, dependency) in service.dependencies.iter().enumerate() { + let _ = writeln!(output, " [{index}]: {}", terminal_text(dependency)); + } + } + let _ = writeln!( + output, + " load-order group: {}\n tag ID: {}\n delayed auto-start: {}\n description: {}", + technical_optional_literal(service.load_order_group.as_deref()), + service + .tag_id + .map(|tag| tag.to_string()) + .unwrap_or_else(|| "none".to_owned()), + service.delayed_auto_start, + technical_optional_literal(service.description.as_deref()) + ); +} + fn render_technical_registry(output: &mut String, entry: &RegistryStartupEntry) { let _ = writeln!( output, @@ -622,10 +880,21 @@ fn registry_entry(artifact: &Artifact) -> Option<&RegistryStartupEntry> { } } +fn service_entry(artifact: &Artifact) -> Option<&systemdiff_core::WindowsService> { + match artifact { + Artifact::WindowsService(service) => Some(service), + _ => None, + } +} + fn is_registry(artifact: &Artifact) -> bool { matches!(artifact, Artifact::RegistryStartup(_)) } +fn is_windows_service(artifact: &Artifact) -> bool { + matches!(artifact, Artifact::WindowsService(_)) +} + fn human_artifact_name(artifact: &Artifact) -> String { match artifact { Artifact::RegistryStartup(entry) => human_value_name(&entry.value_name), @@ -639,6 +908,92 @@ fn human_artifact_name(artifact: &Artifact) -> String { } } +fn human_service_name(service: &systemdiff_core::WindowsService) -> String { + service + .display_name + .as_deref() + .filter(|value| !value.is_empty()) + .map(terminal_text) + .unwrap_or_else(|| terminal_text(&service.service_name)) +} + +fn human_service_start(start_type: u32, delayed_auto_start: bool) -> String { + match start_type { + 0 => "Boot start".to_owned(), + 1 => "System start".to_owned(), + 2 if delayed_auto_start => "Automatic (delayed start)".to_owned(), + 2 => "Automatic".to_owned(), + 3 => "Manual (on demand)".to_owned(), + 4 => "Disabled".to_owned(), + value => format!("Unknown (raw start type {value})"), + } +} + +fn human_error_control(error_control: u32) -> String { + match error_control { + 0 => "Ignore (raw value 0)".to_owned(), + 1 => "Normal (raw value 1)".to_owned(), + 2 => "Severe (raw value 2)".to_owned(), + 3 => "Critical (raw value 3)".to_owned(), + value => format!("Unknown (raw value {value})"), + } +} + +fn human_optional_text(value: Option<&str>) -> String { + value + .map(terminal_text) + .unwrap_or_else(|| "Not set".to_owned()) +} + +fn human_optional_u32(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "Not set".to_owned()) +} + +fn human_bool(value: bool) -> &'static str { + if value { "Yes" } else { "No" } +} + +fn human_dependencies(dependencies: &[String]) -> String { + if dependencies.is_empty() { + "None".to_owned() + } else { + dependencies + .iter() + .map(|dependency| terminal_text(dependency)) + .collect::>() + .join(", ") + } +} + +fn technical_optional_literal(value: Option<&str>) -> String { + value + .map(technical_literal) + .unwrap_or_else(|| "none".to_owned()) +} + +fn technical_start_type(start_type: u32) -> &'static str { + match start_type { + 0 => "boot", + 1 => "system", + 2 => "automatic", + 3 => "manual/on_demand", + 4 => "disabled", + _ => "unknown native value", + } +} + +fn technical_error_control(error_control: u32) -> &'static str { + match error_control { + 0 => "ignore", + 1 => "normal", + 2 => "severe", + 3 => "critical", + _ => "unknown native value", + } +} + fn human_value_name(name: &RegistryValueName) -> String { match name { RegistryValueName::Decoded { value } if value.is_empty() => { @@ -818,6 +1173,9 @@ fn human_view_suffix(view: RegistryView) -> &'static str { } fn human_scope_label(collector_id: &str, scope_id: &str) -> String { + if collector_id == "windows.services" && scope_id == "current_token.win32" { + return "Windows services visible to the current token".to_owned(); + } if collector_id != "windows.registry.startup" { return format!( "{}/{}", diff --git a/crates/systemdiff-report/tests/human_diff.rs b/crates/systemdiff-report/tests/human_diff.rs index 04b82eb..1aac2f9 100644 --- a/crates/systemdiff-report/tests/human_diff.rs +++ b/crates/systemdiff-report/tests/human_diff.rs @@ -1,7 +1,7 @@ use systemdiff_core::{ Artifact, ArtifactKey, CollectorStatus, RegistryDecodedValue, RegistryHive, RegistryStartupEntry, RegistryStartupKind, RegistryValueDecoding, RegistryValueName, - RegistryView, RunOncePrefixSemantics, Snapshot, + RegistryView, RunOncePrefixSemantics, Snapshot, WindowsService, }; use systemdiff_diff::{ ArtifactChange, ChangeKind, DiffDocument, DiffOptions, DiffWarning, DiffWarningCode, @@ -89,6 +89,41 @@ fn change( } } +fn windows_service( + service_name: &str, + display_name: Option<&str>, + start_type: u32, + delayed_auto_start: bool, +) -> Artifact { + Artifact::WindowsService(WindowsService { + service_name: service_name.to_owned(), + display_name: display_name.map(str::to_owned), + service_type: 0x10, + start_type, + error_control: 1, + binary_path: "C:\\Program Files\\Example\\service.exe --service".to_owned(), + account: Some("NT AUTHORITY\\LocalService".to_owned()), + dependencies: vec!["RpcSs".to_owned(), "+NetworkProvider".to_owned()], + load_order_group: Some("ExampleGroup".to_owned()), + tag_id: Some(7), + delayed_auto_start, + description: Some("Provides an example background service.".to_owned()), + }) +} + +fn service_change(change_id: &str, canonical_id: &str, change: ChangeKind) -> ArtifactChange { + ArtifactChange { + change_id: change_id.to_owned(), + key: ArtifactKey { + collector_id: "windows.services".to_owned(), + scope_id: "current_token.win32".to_owned(), + artifact_kind: "windows_service".to_owned(), + canonical_id: canonical_id.to_owned(), + }, + change, + } +} + fn diff(changes: Vec) -> DiffDocument { DiffDocument { document_type: "systemdiff.diff".to_owned(), @@ -559,6 +594,270 @@ fn technical_multi_string_keeps_element_boundaries_and_empty_values() { assert!(output.contains(r#"multi_string (3 elements): [0]="a, b"; [1]=""; [2]="c""#)); } +#[test] +fn service_changes_have_a_dedicated_factual_human_group() { + let added = windows_service("ExampleUpdater", Some("Example Update Service"), 2, true); + let removed = windows_service("LegacyAgent", None, 4, false); + let document = diff(vec![ + service_change( + "change:v1:00000000", + "service-a", + ChangeKind::Added { after: added }, + ), + service_change( + "change:v1:00000001", + "service-b", + ChangeKind::Removed { before: removed }, + ), + ]); + + let output = render_terminal(&document); + + assert_eq!(occurrence_count(&output, "Windows service changes"), 1); + assert!(!output.contains("Other evidence changes")); + for expected in [ + "Example Update Service", + "Added (Windows service)", + "Service name: ExampleUpdater", + "Automatic (delayed start)", + r#"C:\Program Files\Example\service.exe --service"#, + r#"NT AUTHORITY\LocalService"#, + "LegacyAgent", + "Removed (Windows service)", + "Disabled", + ] { + assert!( + output.contains(expected), + "human output omitted {expected:?}" + ); + } + assert!(!output.contains("service-a")); + assert!(!output.to_ascii_lowercase().contains("malicious")); + assert!(!output.to_ascii_lowercase().contains("suspicious")); +} + +#[test] +fn modified_service_lists_only_fields_that_changed() { + let before = windows_service("ExampleUpdater", Some("Example Update Service"), 3, false); + let mut after = before.clone(); + let Artifact::WindowsService(after_service) = &mut after else { + unreachable!("helper must return a service") + }; + after_service.start_type = 2; + after_service.delayed_auto_start = true; + after_service.description = Some("Updated description".to_owned()); + let document = diff(vec![service_change( + "change:v1:00000000", + "service-a", + ChangeKind::Modified { before, after }, + )]); + + let output = render_terminal(&document); + + assert!(output.contains("Modified (Windows service)")); + assert!(output.contains("Start:")); + assert!(output.contains("Before: Manual (on demand)")); + assert!(output.contains("After: Automatic (delayed start)")); + assert!(output.contains("Delayed automatic start configured:")); + assert!(output.contains("Before: No")); + assert!(output.contains("After: Yes")); + assert!(output.contains("Description:")); + assert!(output.contains("Before: Provides an example background service.")); + assert!(output.contains("After: Updated description")); + assert!(!output.contains("Binary path:")); + assert!(!output.contains("Account:")); + assert!(!output.contains("Dependencies:")); + assert!(!output.contains("Load-order group:")); + assert!(!output.contains("Tag ID:")); + assert!(!output.contains("Error control:")); +} + +#[test] +fn delayed_flag_change_is_visible_for_non_automatic_service() { + let before = windows_service("ManualService", None, 3, false); + let mut after = before.clone(); + let Artifact::WindowsService(after_service) = &mut after else { + unreachable!("helper must return a service") + }; + after_service.delayed_auto_start = true; + let document = diff(vec![service_change( + "change:v1:00000000", + "manual-service", + ChangeKind::Modified { before, after }, + )]); + + let output = render_terminal(&document); + + assert!(output.contains("Delayed automatic start configured:")); + assert!(output.contains("Before: No")); + assert!(output.contains("After: Yes")); + assert!(!output.contains("Start:")); +} + +#[test] +fn inconclusive_service_absence_does_not_claim_removal() { + let observed = windows_service("MaybePresent", Some("Maybe Present Service"), 3, false); + let mut document = diff(vec![service_change( + "change:v1:00000000", + "service-a", + ChangeKind::Inconclusive { + before: Some(observed), + after: None, + reason: InconclusiveReason::CoverageIncomplete, + }, + )]); + document.warnings.push(DiffWarning { + code: DiffWarningCode::CoverageIncomplete, + collector_id: "windows.services".to_owned(), + scope_id: "current_token.win32".to_owned(), + before_status: Some(CollectorStatus::Partial), + after_status: Some(CollectorStatus::Partial), + }); + + let output = render_terminal(&document); + + assert!(output.contains("Inconclusive (Windows service)")); + assert!( + output + .to_ascii_lowercase() + .contains("current-token service coverage was incomplete in the after snapshot") + ); + assert!(output.contains("removal could not be confirmed")); + assert!(output.contains("could not be confirmed")); + assert!(output.contains("Windows services visible to the current token")); + assert!(!output.contains("windows.services/current_token.win32")); + assert!(!output.contains("Removed")); +} + +#[test] +fn human_service_start_labels_keep_unknown_native_values_factual() { + let document = diff(vec![ + service_change( + "change:v1:00000000", + "boot", + ChangeKind::Added { + after: windows_service("BootService", None, 0, false), + }, + ), + service_change( + "change:v1:00000001", + "system", + ChangeKind::Added { + after: windows_service("SystemService", None, 1, false), + }, + ), + service_change( + "change:v1:00000002", + "unknown", + ChangeKind::Added { + after: windows_service("FutureService", None, 99, false), + }, + ), + ]); + + let output = render_terminal(&document); + + assert!(output.contains("Boot start")); + assert!(output.contains("System start")); + assert!(output.contains("Unknown (raw start type 99)")); +} + +#[test] +fn technical_service_output_preserves_every_field_and_known_absence() { + let artifact = windows_service("ExampleUpdater", Some("Example Update Service"), 99, false); + let document = diff(vec![service_change( + "change:v1:00000000", + "service-a", + ChangeKind::Added { after: artifact }, + )]); + + let output = render_technical(&document, &before_fixture(), &after_fixture()); + + for expected in [ + "service name: ExampleUpdater", + r#"display name: "Example Update Service""#, + "service type: 16", + "start type: 99 (unknown native value)", + "error control: 1 (normal)", + r#"binary path: C:\Program Files\Example\service.exe --service"#, + r#"account: "NT AUTHORITY\\LocalService""#, + "dependencies (2):", + "[0]: RpcSs", + "[1]: +NetworkProvider", + r#"load-order group: "ExampleGroup""#, + "tag ID: 7", + "delayed auto-start: false", + r#"description: "Provides an example background service.""#, + ] { + assert!( + output.contains(expected), + "technical service output omitted {expected:?}\n{output}" + ); + } + + let mut absent = windows_service("MinimalService", None, 3, false); + let Artifact::WindowsService(service) = &mut absent else { + unreachable!("helper must return a service") + }; + service.account = None; + service.dependencies.clear(); + service.load_order_group = None; + service.tag_id = None; + service.description = None; + let absent_document = diff(vec![service_change( + "change:v1:00000001", + "service-b", + ChangeKind::Added { after: absent }, + )]); + let absent_output = render_technical(&absent_document, &before_fixture(), &after_fixture()); + + assert!(absent_output.contains("display name: none")); + assert!(absent_output.contains("account: none")); + assert!(absent_output.contains("dependencies: none")); + assert!(absent_output.contains("load-order group: none")); + assert!(absent_output.contains("tag ID: none")); + assert!(absent_output.contains("description: none")); +} + +#[test] +fn service_evidence_cannot_inject_terminal_lines_or_bidi_controls() { + let artifact = Artifact::WindowsService(WindowsService { + service_name: "Evil\nService\u{202e}".to_owned(), + display_name: Some("Display\r\nInjected\u{1b}[31m".to_owned()), + service_type: 0x10, + start_type: 2, + error_control: 1, + binary_path: "C:\\evil\tservice.exe\u{2028}".to_owned(), + account: Some("Account\u{2066}".to_owned()), + dependencies: vec!["Dep\nOne".to_owned()], + load_order_group: Some("Group\u{202a}".to_owned()), + tag_id: None, + delayed_auto_start: false, + description: Some("Description\rTwo".to_owned()), + }); + let document = diff(vec![service_change( + "change:v1:00000000", + "service-a", + ChangeKind::Added { after: artifact }, + )]); + + for output in [ + render_terminal(&document), + render_technical(&document, &before_fixture(), &after_fixture()), + ] { + assert!(!output.contains('\u{1b}')); + assert!(!output.contains('\r')); + assert!(!output.contains('\t')); + assert!(!output.contains('\u{202e}')); + assert!(!output.contains('\u{2028}')); + assert!(!output.contains('\u{2066}')); + assert!(!output.contains('\u{202a}')); + assert!(output.contains("Display\\r\\nInjected\\u{1b}[31m")); + assert!(output.contains("Evil\\nService\\u{202e}")); + assert!(output.contains("C:\\evil\\tservice.exe\\u{2028}")); + } +} + #[test] fn json_output_remains_the_pretty_serialized_diff_with_one_trailing_newline() { let artifact = run_entry( diff --git a/crates/systemdiff-windows/Cargo.toml b/crates/systemdiff-windows/Cargo.toml index cabf225..2413d67 100644 --- a/crates/systemdiff-windows/Cargo.toml +++ b/crates/systemdiff-windows/Cargo.toml @@ -15,6 +15,7 @@ windows = { workspace = true, features = [ "Win32_Foundation", "Win32_Security", "Win32_System_Registry", + "Win32_System_Services", "Win32_System_SystemInformation", "Win32_System_Threading", ] } diff --git a/crates/systemdiff-windows/src/lib.rs b/crates/systemdiff-windows/src/lib.rs index 5c4be4b..4fd34df 100644 --- a/crates/systemdiff-windows/src/lib.rs +++ b/crates/systemdiff-windows/src/lib.rs @@ -2,7 +2,48 @@ mod platform; mod registry; +mod services; mod win32; +#[cfg(windows)] +mod win32_services; + +#[cfg(not(windows))] +mod win32_services { + use crate::services::{ + RawServiceConfig, ServiceDataSource, ServiceEnumeration, ServiceFailure, ServiceFailureKind, + }; + + pub(crate) struct Win32ServiceSource; + + impl Win32ServiceSource { + pub(crate) fn new() -> Self { + Self + } + } + + impl ServiceDataSource for Win32ServiceSource { + fn enumerate(&mut self) -> Result { + Err(unsupported()) + } + + fn read_config_once( + &mut self, + _service_name_utf16: &[u16], + ) -> Result { + Err(unsupported()) + } + } + + fn unsupported() -> ServiceFailure { + ServiceFailure { + kind: ServiceFailureKind::Other, + code: "service_platform_unsupported", + message: "Windows service collection is unavailable on this platform.", + stage: "platform", + native_code: None, + } + } +} use std::error::Error; use std::fmt; @@ -16,9 +57,12 @@ pub use registry::{ MAX_REGISTRY_VALUES_PER_SCOPE, REGISTRY_STARTUP_COLLECTOR_ID, REGISTRY_STARTUP_COLLECTOR_VERSION, RegistryStartupCollector, }; +pub use services::{ + MAX_SERVICE_EVIDENCE_BYTES, MAX_SERVICES_COLLECTOR_EVIDENCE_BYTES, MAX_SERVICES_PER_SCOPE, + SERVICES_COLLECTOR_ID, SERVICES_COLLECTOR_VERSION, WindowsServicesCollector, +}; use systemdiff_core::{CollectorDescriptor, PrivilegeRequirement}; -pub const SERVICES_COLLECTOR_ID: &str = "windows.services"; pub const SCHEDULED_TASKS_COLLECTOR_ID: &str = "windows.scheduled_tasks"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -40,13 +84,8 @@ pub fn mvp_collector_plans() -> Vec { implementation: ImplementationStatus::Implemented, }, CollectorPlan { - descriptor: CollectorDescriptor { - id: SERVICES_COLLECTOR_ID.to_owned(), - version: 1, - description: "Win32 service configuration, excluding drivers.".to_owned(), - privilege: PrivilegeRequirement::ObjectAclDependent, - }, - implementation: ImplementationStatus::Planned, + descriptor: services::descriptor(), + implementation: ImplementationStatus::Implemented, }, CollectorPlan { descriptor: CollectorDescriptor { @@ -71,7 +110,8 @@ pub fn capture_snapshot( let host = platform::host_metadata(); let privilege = platform::privilege_state(); let context = CollectionContext { privilege }; - let outcome = RegistryStartupCollector.collect(&context); + let registry_outcome = RegistryStartupCollector.collect(&context); + let services_outcome = WindowsServicesCollector.collect(&context); assemble_snapshot( SnapshotMetadata { systemdiff_version, @@ -83,7 +123,7 @@ pub fn capture_snapshot( policy: None, }, }, - vec![outcome], + vec![registry_outcome, services_outcome], ) .map_err(CaptureError::InvalidSnapshot) } @@ -120,7 +160,7 @@ mod tests { use std::collections::BTreeSet; #[test] - fn collector_ids_are_unique_versioned_and_registry_is_implemented() { + fn collector_ids_are_unique_versioned_and_registry_and_services_are_implemented() { let plans = mvp_collector_plans(); let ids: BTreeSet<_> = plans .iter() @@ -130,10 +170,7 @@ mod tests { assert_eq!(plans.len(), ids.len()); assert!(plans.iter().all(|plan| plan.descriptor.version > 0)); assert_eq!(plans[0].implementation, ImplementationStatus::Implemented); - assert!( - plans[1..] - .iter() - .all(|plan| plan.implementation == ImplementationStatus::Planned) - ); + assert_eq!(plans[1].implementation, ImplementationStatus::Implemented); + assert_eq!(plans[2].implementation, ImplementationStatus::Planned); } } diff --git a/crates/systemdiff-windows/src/services.rs b/crates/systemdiff-windows/src/services.rs new file mode 100644 index 0000000..a5f27aa --- /dev/null +++ b/crates/systemdiff-windows/src/services.rs @@ -0,0 +1,587 @@ +use std::collections::BTreeMap; +use systemdiff_core::{ + Artifact, CollectionContext, CollectionOutcome, Collector, CollectorDescriptor, CollectorRun, + CollectorStatus, Diagnostic, Observation, PrivilegeRequirement, ScopeCoverage, + WINDOWS_SERVICES_COLLECTOR_ID, WINDOWS_SERVICES_COLLECTOR_VERSION, WINDOWS_SERVICES_SCOPE_ID, + WindowsService, windows_service_identity, +}; + +pub const SERVICES_COLLECTOR_ID: &str = WINDOWS_SERVICES_COLLECTOR_ID; +pub const SERVICES_COLLECTOR_VERSION: u32 = WINDOWS_SERVICES_COLLECTOR_VERSION; +pub const MAX_SERVICES_PER_SCOPE: usize = 4_096; +pub const MAX_SERVICE_EVIDENCE_BYTES: usize = 32 * 1024; +pub const MAX_SERVICES_COLLECTOR_EVIDENCE_BYTES: usize = 16 * 1024 * 1024; +const MAX_CONFIG_READS: usize = 3; + +pub fn descriptor() -> CollectorDescriptor { + CollectorDescriptor { + id: SERVICES_COLLECTOR_ID.to_owned(), + version: SERVICES_COLLECTOR_VERSION, + description: "Win32 service configuration visible to the current token, excluding drivers." + .to_owned(), + privilege: PrivilegeRequirement::ObjectAclDependent, + } +} + +pub struct WindowsServicesCollector; + +impl Collector for WindowsServicesCollector { + fn descriptor(&self) -> CollectorDescriptor { + descriptor() + } + + fn collect(&self, context: &CollectionContext) -> CollectionOutcome { + let mut source = crate::win32_services::Win32ServiceSource::new(); + collect_with_source(&mut source, context) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RawServiceName { + pub name_utf16: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RawServiceConfig { + pub service_name_utf16: Vec, + pub display_name_utf16: Option>, + pub service_type: u32, + pub start_type: u32, + pub error_control: u32, + pub binary_path_utf16: Vec, + pub account_utf16: Option>, + pub dependencies_utf16: Vec>, + pub load_order_group_utf16: Option>, + pub tag_id: Option, + pub delayed_auto_start: bool, + pub description_utf16: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServiceEnumeration { + pub names: Vec, + pub issues: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ServiceFailureKind { + AccessDenied, + DoesNotExist, + InvalidData, + ResourceLimit, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServiceFailure { + pub kind: ServiceFailureKind, + pub code: &'static str, + pub message: &'static str, + pub stage: &'static str, + pub native_code: Option, +} + +pub(crate) trait ServiceDataSource { + fn enumerate(&mut self) -> Result; + fn read_config_once( + &mut self, + service_name_utf16: &[u16], + ) -> Result; +} + +fn collect_with_source( + source: &mut S, + _context: &CollectionContext, +) -> CollectionOutcome { + collect_with_source_and_budget(source, MAX_SERVICES_COLLECTOR_EVIDENCE_BYTES) +} + +fn collect_with_source_and_budget( + source: &mut S, + collector_budget: usize, +) -> CollectionOutcome { + let enumeration = match source.enumerate() { + Ok(enumeration) => enumeration, + Err(failure) => return failed_outcome(failure), + }; + + let mut diagnostics = vec![Diagnostic { + code: "service_visibility_best_effort".to_owned(), + message: + "Windows can silently omit services that the current token cannot query for status." + .to_owned(), + stage: Some("enumerate".to_owned()), + native_code: None, + scope_id: Some(WINDOWS_SERVICES_SCOPE_ID.to_owned()), + }]; + diagnostics.extend(enumeration.issues.into_iter().map(diagnostic)); + let mut by_identity: BTreeMap>> = BTreeMap::new(); + for name in enumeration.names { + by_identity + .entry(windows_service_identity(&name.name_utf16)) + .or_default() + .push(name.name_utf16); + } + + let mut observations = Vec::new(); + let mut retained_bytes = 0_usize; + for (canonical_id, names) in by_identity { + if names.len() != 1 { + diagnostics.push(diagnostic_for_identity( + ServiceFailure { + kind: ServiceFailureKind::InvalidData, + code: "service_identity_collision", + message: "Multiple enumerated services produced one Collector identity.", + stage: "normalize", + native_code: None, + }, + &canonical_id, + )); + continue; + } + let name = &names[0]; + let raw = match read_stable_config(source, name) { + Ok(raw) => raw, + Err(failure) => { + diagnostics.push(diagnostic_for_identity(failure, &canonical_id)); + continue; + } + }; + let service = match normalize_service(raw) { + Ok(service) => service, + Err(failure) => { + diagnostics.push(diagnostic_for_identity(failure, &canonical_id)); + continue; + } + }; + let evidence_bytes = service_evidence_bytes(&service).unwrap_or(usize::MAX); + let next = retained_bytes.saturating_add(evidence_bytes); + if evidence_bytes > MAX_SERVICE_EVIDENCE_BYTES || next > collector_budget { + diagnostics.push(diagnostic_for_identity( + ServiceFailure { + kind: ServiceFailureKind::ResourceLimit, + code: "service_resource_limit", + message: "Service evidence exceeded a SystemDiff capture budget.", + stage: "normalize", + native_code: None, + }, + &canonical_id, + )); + continue; + } + retained_bytes = next; + observations.push(Observation { + collector_id: SERVICES_COLLECTOR_ID.to_owned(), + collector_version: SERVICES_COLLECTOR_VERSION, + scope_id: WINDOWS_SERVICES_SCOPE_ID.to_owned(), + canonical_id, + artifact: Artifact::WindowsService(service), + }); + } + observations.sort_by_key(|observation| observation.key()); + diagnostics.sort_by(|left, right| { + (&left.code, &left.stage, left.native_code, &left.message).cmp(&( + &right.code, + &right.stage, + right.native_code, + &right.message, + )) + }); + diagnostics.dedup(); + + CollectionOutcome { + run: CollectorRun { + id: SERVICES_COLLECTOR_ID.to_owned(), + version: SERVICES_COLLECTOR_VERSION, + status: CollectorStatus::Partial, + coverage: vec![ScopeCoverage { + scope_id: WINDOWS_SERVICES_SCOPE_ID.to_owned(), + status: CollectorStatus::Partial, + }], + diagnostics, + }, + observations, + } +} + +fn read_stable_config( + source: &mut S, + name: &[u16], +) -> Result { + let mut previous = source.read_config_once(name)?; + for _ in 1..MAX_CONFIG_READS { + let current = source.read_config_once(name)?; + if current == previous { + return Ok(current); + } + previous = current; + } + Err(ServiceFailure { + kind: ServiceFailureKind::Other, + code: "service_changed_during_scan", + message: "A service changed during bounded configuration reads.", + stage: "query_config", + native_code: None, + }) +} + +fn normalize_service(raw: RawServiceConfig) -> Result { + let strict = |units: Vec| String::from_utf16(&units).map_err(|_| invalid_utf16_failure()); + let optional = |units: Option>| { + units + .map(strict) + .transpose() + .map(|value| value.filter(|text| !text.is_empty())) + }; + let service_name = strict(raw.service_name_utf16)?; + if service_name.is_empty() { + return Err(invalid_data_failure("A service name was empty.")); + } + let win32_base = raw.service_type & 0x30; + if !matches!(win32_base, 0x10 | 0x20) || raw.service_type & 0x0f != 0 { + return Err(invalid_data_failure( + "A non-Win32 or driver-only service was returned by the Win32 filter.", + )); + } + Ok(WindowsService { + service_name, + display_name: optional(raw.display_name_utf16)?, + service_type: raw.service_type, + start_type: raw.start_type, + error_control: raw.error_control, + binary_path: strict(raw.binary_path_utf16)?, + account: optional(raw.account_utf16)?, + dependencies: raw + .dependencies_utf16 + .into_iter() + .map(strict) + .collect::>()?, + load_order_group: optional(raw.load_order_group_utf16)?, + tag_id: raw.tag_id, + delayed_auto_start: raw.delayed_auto_start, + description: optional(raw.description_utf16)?, + }) +} + +fn service_evidence_bytes(service: &WindowsService) -> Option { + let mut units = service.service_name.encode_utf16().count(); + for value in [ + service.display_name.as_deref(), + Some(service.binary_path.as_str()), + service.account.as_deref(), + service.load_order_group.as_deref(), + service.description.as_deref(), + ] + .into_iter() + .flatten() + { + units = units.checked_add(value.encode_utf16().count())?; + } + for dependency in &service.dependencies { + units = units.checked_add(dependency.encode_utf16().count())?; + } + units.checked_mul(2) +} + +fn failed_outcome(failure: ServiceFailure) -> CollectionOutcome { + let status = if failure.kind == ServiceFailureKind::AccessDenied { + CollectorStatus::PermissionDenied + } else { + CollectorStatus::Failed + }; + CollectionOutcome { + run: CollectorRun { + id: SERVICES_COLLECTOR_ID.to_owned(), + version: SERVICES_COLLECTOR_VERSION, + status, + coverage: vec![ScopeCoverage { + scope_id: WINDOWS_SERVICES_SCOPE_ID.to_owned(), + status, + }], + diagnostics: vec![diagnostic(failure)], + }, + observations: Vec::new(), + } +} + +fn diagnostic(failure: ServiceFailure) -> Diagnostic { + Diagnostic { + code: failure.code.to_owned(), + message: failure.message.to_owned(), + stage: Some(failure.stage.to_owned()), + native_code: failure.native_code, + scope_id: Some(WINDOWS_SERVICES_SCOPE_ID.to_owned()), + } +} + +fn diagnostic_for_identity(failure: ServiceFailure, canonical_id: &str) -> Diagnostic { + let mut diagnostic = diagnostic(failure); + diagnostic.message = format!("{} Artifact identity: {canonical_id}.", diagnostic.message); + diagnostic +} + +fn invalid_utf16_failure() -> ServiceFailure { + invalid_data_failure("Service configuration contained malformed UTF-16.") +} + +fn invalid_data_failure(message: &'static str) -> ServiceFailure { + ServiceFailure { + kind: ServiceFailureKind::InvalidData, + code: "service_invalid_data", + message, + stage: "normalize", + native_code: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use systemdiff_core::PrivilegeState; + + #[derive(Clone)] + struct FakeSource { + names: Result, + reads: BTreeMap, Vec>>, + } + + impl ServiceDataSource for FakeSource { + fn enumerate(&mut self) -> Result { + self.names.clone() + } + + fn read_config_once( + &mut self, + service_name_utf16: &[u16], + ) -> Result { + self.reads + .get_mut(service_name_utf16) + .and_then(|reads| (!reads.is_empty()).then(|| reads.remove(0))) + .unwrap_or_else(|| Err(invalid_data_failure("Missing fake read."))) + } + } + + fn utf16(value: &str) -> Vec { + value.encode_utf16().collect() + } + + fn config(name: &str) -> RawServiceConfig { + RawServiceConfig { + service_name_utf16: utf16(name), + display_name_utf16: Some(utf16("Example Service")), + service_type: 0x10, + start_type: 3, + error_control: 1, + binary_path_utf16: utf16(r#"%SystemRoot%\Example.exe --service"#), + account_utf16: Some(utf16("LocalSystem")), + dependencies_utf16: vec![utf16("RpcSs"), utf16("+NetworkProvider")], + load_order_group_utf16: None, + tag_id: None, + delayed_auto_start: false, + description_utf16: None, + } + } + + fn source_with(name: &str, reads: Vec>) -> FakeSource { + FakeSource { + names: Ok(ServiceEnumeration { + names: vec![RawServiceName { + name_utf16: utf16(name), + }], + issues: Vec::new(), + }), + reads: BTreeMap::from([(utf16(name), reads)]), + } + } + + fn context() -> CollectionContext { + CollectionContext { + privilege: PrivilegeState::StandardUser, + } + } + + #[test] + fn successful_collection_is_best_effort_partial_and_preserves_raw_evidence() { + let raw = config("ExampleService_1a2b3"); + let mut source = source_with("ExampleService_1a2b3", vec![Ok(raw.clone()), Ok(raw)]); + let outcome = collect_with_source(&mut source, &context()); + assert_eq!(outcome.run.status, CollectorStatus::Partial); + assert_eq!(outcome.observations.len(), 1); + let Artifact::WindowsService(service) = &outcome.observations[0].artifact else { + panic!("expected service") + }; + assert_eq!(service.service_name, "ExampleService_1a2b3"); + assert_eq!(service.binary_path, r#"%SystemRoot%\Example.exe --service"#); + assert_eq!(service.dependencies, ["RpcSs", "+NetworkProvider"]); + assert!( + outcome + .run + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "service_visibility_best_effort" }) + ); + } + + #[test] + fn query_failure_omits_atomic_observation_and_preserves_siblings() { + let first = config("First"); + let failure = ServiceFailure { + kind: ServiceFailureKind::AccessDenied, + code: "service_access_denied", + message: "Service configuration access was denied.", + stage: "open_service", + native_code: Some(5), + }; + let mut source = FakeSource { + names: Ok(ServiceEnumeration { + names: vec![ + RawServiceName { + name_utf16: utf16("Second"), + }, + RawServiceName { + name_utf16: utf16("First"), + }, + ], + issues: Vec::new(), + }), + reads: BTreeMap::from([ + (utf16("First"), vec![Ok(first.clone()), Ok(first)]), + (utf16("Second"), vec![Err(failure)]), + ]), + }; + let outcome = collect_with_source(&mut source, &context()); + assert_eq!(outcome.observations.len(), 1); + assert!(outcome.run.diagnostics.iter().any(|diagnostic| { + diagnostic.code == "service_access_denied" + && diagnostic + .message + .contains(&windows_service_identity(&utf16("Second"))) + && !diagnostic.message.contains("Second") + })); + } + + #[test] + fn per_service_failures_retain_distinct_private_identities() { + let failure = ServiceFailure { + kind: ServiceFailureKind::AccessDenied, + code: "service_access_denied", + message: "Service configuration access was denied.", + stage: "open_service", + native_code: Some(5), + }; + let mut source = FakeSource { + names: Ok(ServiceEnumeration { + names: vec![ + RawServiceName { + name_utf16: utf16("PrivateNameOne"), + }, + RawServiceName { + name_utf16: utf16("PrivateNameTwo"), + }, + ], + issues: Vec::new(), + }), + reads: BTreeMap::from([ + (utf16("PrivateNameOne"), vec![Err(failure.clone())]), + (utf16("PrivateNameTwo"), vec![Err(failure)]), + ]), + }; + + let outcome = collect_with_source(&mut source, &context()); + let failures = outcome + .run + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.code == "service_access_denied") + .collect::>(); + + assert_eq!(failures.len(), 2); + assert_ne!(failures[0].message, failures[1].message); + assert!( + failures + .iter() + .all(|diagnostic| !diagnostic.message.contains("PrivateName")) + ); + } + + #[test] + fn changing_configuration_has_three_read_bound_and_is_omitted() { + let mut first = config("Changing"); + let mut second = first.clone(); + let mut third = first.clone(); + first.start_type = 2; + second.start_type = 3; + third.start_type = 4; + let mut source = source_with("Changing", vec![Ok(first), Ok(second), Ok(third)]); + let outcome = collect_with_source(&mut source, &context()); + assert!(outcome.observations.is_empty()); + assert!( + outcome + .run + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "service_changed_during_scan" }) + ); + } + + #[test] + fn malformed_utf16_and_driver_types_are_omitted_without_lossy_text() { + let mut malformed = config("Malformed"); + malformed.description_utf16 = Some(vec![0xd800]); + let mut source = source_with("Malformed", vec![Ok(malformed.clone()), Ok(malformed)]); + let outcome = collect_with_source(&mut source, &context()); + assert!(outcome.observations.is_empty()); + assert!( + outcome + .run + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "service_invalid_data" }) + ); + + let mut driver = config("Driver"); + driver.service_type = 1; + let mut source = source_with("Driver", vec![Ok(driver.clone()), Ok(driver)]); + assert!( + collect_with_source(&mut source, &context()) + .observations + .is_empty() + ); + } + + #[test] + fn aggregate_budget_selection_is_deterministic_by_identity() { + let one = config("One"); + let two = config("Two"); + let make = |reversed: bool| { + let mut names = vec![ + RawServiceName { + name_utf16: utf16("One"), + }, + RawServiceName { + name_utf16: utf16("Two"), + }, + ]; + if reversed { + names.reverse(); + } + FakeSource { + names: Ok(ServiceEnumeration { + names, + issues: Vec::new(), + }), + reads: BTreeMap::from([ + (utf16("One"), vec![Ok(one.clone()), Ok(one.clone())]), + (utf16("Two"), vec![Ok(two.clone()), Ok(two.clone())]), + ]), + } + }; + let one_budget = service_evidence_bytes(&normalize_service(one.clone()).unwrap()).unwrap(); + let mut forward = make(false); + let mut reverse = make(true); + let left = collect_with_source_and_budget(&mut forward, one_budget); + let right = collect_with_source_and_budget(&mut reverse, one_budget); + assert_eq!(left, right); + assert_eq!(left.observations.len(), 1); + } +} diff --git a/crates/systemdiff-windows/src/win32_services.rs b/crates/systemdiff-windows/src/win32_services.rs new file mode 100644 index 0000000..084365b --- /dev/null +++ b/crates/systemdiff-windows/src/win32_services.rs @@ -0,0 +1,693 @@ +use crate::services::{ + MAX_SERVICES_PER_SCOPE, RawServiceConfig, RawServiceName, ServiceDataSource, + ServiceEnumeration, ServiceFailure, ServiceFailureKind, +}; +use std::collections::BTreeMap; +use std::mem::{align_of, size_of}; +use std::ptr; +use systemdiff_core::windows_service_identity; +use windows::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_INSUFFICIENT_BUFFER, ERROR_MORE_DATA, ERROR_SERVICE_DOES_NOT_EXIST, + ERROR_SERVICE_MARKED_FOR_DELETE, WIN32_ERROR, +}; +use windows::Win32::System::Services::{ + CloseServiceHandle, ENUM_SERVICE_STATUS_PROCESSW, EnumServicesStatusExW, OpenSCManagerW, + OpenServiceW, QUERY_SERVICE_CONFIGW, QueryServiceConfig2W, QueryServiceConfigW, + SC_ENUM_PROCESS_INFO, SC_HANDLE, SC_MANAGER_ENUMERATE_SERVICE, + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, SERVICE_CONFIG_DESCRIPTION, + SERVICE_DELAYED_AUTO_START_INFO, SERVICE_DESCRIPTIONW, SERVICE_QUERY_CONFIG, SERVICE_STATE_ALL, + SERVICE_WIN32, +}; +use windows::core::{Error as WindowsError, PCWSTR, PWSTR}; + +const MAX_ENUMERATION_BUFFER_BYTES: usize = 256 * 1024; +const MAX_QUERY_BUFFER_BYTES: usize = 8 * 1024; +const MAX_ENUMERATION_PAGES: usize = 64; +const MAX_QUERY_ATTEMPTS: usize = 3; + +pub(crate) struct Win32ServiceSource { + manager: Option, +} + +impl Win32ServiceSource { + pub(crate) fn new() -> Self { + Self { manager: None } + } + + fn manager(&mut self) -> Result { + if self.manager.is_none() { + // SAFETY: both null strings select the local machine and active + // service database. The requested right permits enumeration only. + let handle = unsafe { + OpenSCManagerW(PCWSTR::null(), PCWSTR::null(), SC_MANAGER_ENUMERATE_SERVICE) + } + .map_err(|error| map_error(error, "open_scm"))?; + self.manager = Some(OwnedServiceHandle::new(handle, "open_scm")?); + } + Ok(self.manager.as_ref().expect("manager initialized").raw()) + } +} + +impl ServiceDataSource for Win32ServiceSource { + fn enumerate(&mut self) -> Result { + enumerate_services(self.manager()?) + } + + fn read_config_once( + &mut self, + service_name_utf16: &[u16], + ) -> Result { + let manager = self.manager()?; + let mut terminated = Vec::with_capacity(service_name_utf16.len().saturating_add(1)); + terminated.extend_from_slice(service_name_utf16); + terminated.push(0); + // SAFETY: the manager handle is owned by self, the name is + // NUL-terminated for this call, and SERVICE_QUERY_CONFIG is read-only. + let raw = + unsafe { OpenServiceW(manager, PCWSTR(terminated.as_ptr()), SERVICE_QUERY_CONFIG) } + .map_err(|error| map_error(error, "open_service"))?; + let service = OwnedServiceHandle::new(raw, "open_service")?; + + let base = query_base_config(service.raw())?; + let config = parse_base_config(&base)?; + let description_buffer = query_config2( + service.raw(), + SERVICE_CONFIG_DESCRIPTION, + "query_description", + )?; + let description = parse_description(&description_buffer)?; + let delayed_buffer = query_config2( + service.raw(), + SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + "query_delayed_auto_start", + )?; + let delayed_auto_start = parse_delayed_auto_start(&delayed_buffer)?; + + Ok(RawServiceConfig { + service_name_utf16: service_name_utf16.to_vec(), + display_name_utf16: config.display_name, + service_type: config.service_type, + start_type: config.start_type, + error_control: config.error_control, + binary_path_utf16: config.binary_path, + account_utf16: config.account, + dependencies_utf16: config.dependencies, + load_order_group_utf16: config.load_order_group, + tag_id: config.tag_id, + delayed_auto_start, + description_utf16: description, + }) + } +} + +struct OwnedServiceHandle(SC_HANDLE); + +impl OwnedServiceHandle { + fn new(handle: SC_HANDLE, stage: &'static str) -> Result { + if handle.is_invalid() { + Err(failure( + ServiceFailureKind::Other, + "service_invalid_handle", + "Windows returned an invalid service handle.", + stage, + None, + )) + } else { + Ok(Self(handle)) + } + } + + fn raw(&self) -> SC_HANDLE { + self.0 + } +} + +impl Drop for OwnedServiceHandle { + fn drop(&mut self) { + // SAFETY: this wrapper is constructed only from a successful SCM or + // service open and owns exactly one non-Copy wrapper lifetime. + let _ = unsafe { CloseServiceHandle(self.0) }; + } +} + +struct AlignedBuffer { + words: Vec, + byte_len: usize, +} + +impl AlignedBuffer { + fn new(byte_len: usize) -> Result { + if byte_len == 0 { + return Err(invalid_buffer( + "Windows requested a zero-length service buffer.", + )); + } + let words = byte_len + .checked_add(size_of::() - 1) + .and_then(|value| value.checked_div(size_of::())) + .ok_or_else(resource_limit)?; + Ok(Self { + words: vec![usize::MAX; words], + byte_len, + }) + } + + fn as_mut_bytes(&mut self) -> &mut [u8] { + // SAFETY: Vec is initialized and suitably aligned. The exposed + // byte slice is restricted to byte_len, which is no larger than the + // allocated word storage. + unsafe { + std::slice::from_raw_parts_mut(self.words.as_mut_ptr().cast::(), self.byte_len) + } + } + + fn as_ptr(&self) -> *const u8 { + self.words.as_ptr().cast() + } + + fn len(&self) -> usize { + self.byte_len + } +} + +fn enumerate_services(manager: SC_HANDLE) -> Result { + let mut required = 0_u32; + let mut returned = 0_u32; + let mut probe_resume = 0_u32; + // SAFETY: manager is a live owned SCM handle; null output requests the + // required size and all output counters are valid for the call. + let probe = unsafe { + EnumServicesStatusExW( + manager, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + None, + &mut required, + &mut returned, + Some(&mut probe_resume), + PCWSTR::null(), + ) + }; + if let Err(error) = probe { + if error_code(&error) != Some(ERROR_MORE_DATA) { + return Err(map_error(error, "enumerate_probe")); + } + } else if returned == 0 { + return Ok(ServiceEnumeration { + names: Vec::new(), + issues: Vec::new(), + }); + } + + let mut requested = checked_buffer_size(required, size_of::())?; + let mut resume = 0_u32; + let mut retained: BTreeMap>> = BTreeMap::new(); + let mut retained_count = 0_usize; + let mut omitted_for_limit = false; + + for _ in 0..MAX_ENUMERATION_PAGES { + let mut buffer = AlignedBuffer::new(requested)?; + required = 0; + returned = 0; + let before_resume = resume; + // SAFETY: buffer is initialized and aligned, counters are valid, and + // manager remains live for the call. Returned pointers are copied only + // after range and termination checks below. + let call = unsafe { + EnumServicesStatusExW( + manager, + SC_ENUM_PROCESS_INFO, + SERVICE_WIN32, + SERVICE_STATE_ALL, + Some(buffer.as_mut_bytes()), + &mut required, + &mut returned, + Some(&mut resume), + PCWSTR::null(), + ) + }; + let more_data = match call { + Ok(()) => false, + Err(error) if error_code(&error) == Some(ERROR_MORE_DATA) => true, + Err(error) => return Err(map_error(error, "enumerate")), + }; + + for name in parse_enumerated_names(&buffer, returned)? { + let identity = windows_service_identity(&name); + retained.entry(identity).or_default().push(name); + retained_count = retained_count.checked_add(1).ok_or_else(resource_limit)?; + while retained_count > MAX_SERVICES_PER_SCOPE { + let last = retained + .keys() + .next_back() + .cloned() + .ok_or_else(resource_limit)?; + let mut remove = false; + if let Some(group) = retained.get_mut(&last) { + group.pop(); + retained_count -= 1; + remove = group.is_empty(); + } + if remove { + retained.remove(&last); + } + omitted_for_limit = true; + } + } + + if !more_data { + let names = retained + .into_values() + .flat_map(|group| group.into_iter()) + .map(|name_utf16| RawServiceName { name_utf16 }) + .collect(); + let issues = omitted_for_limit + .then(resource_limit) + .into_iter() + .collect::>(); + return Ok(ServiceEnumeration { names, issues }); + } + if resume == before_resume { + return Err(failure( + ServiceFailureKind::Other, + "service_enumeration_stalled", + "Service enumeration made no progress within its bounded pagination loop.", + "enumerate", + None, + )); + } + if required != 0 { + requested = checked_buffer_size(required, size_of::())?; + } + } + + Err(failure( + ServiceFailureKind::ResourceLimit, + "service_resource_limit", + "Service enumeration exceeded the SystemDiff page limit.", + "enumerate", + None, + )) +} + +fn checked_buffer_size(required: u32, minimum: usize) -> Result { + let required = usize::try_from(required).map_err(|_| resource_limit())?; + Ok(required.max(minimum).min(MAX_ENUMERATION_BUFFER_BYTES)) +} + +fn parse_enumerated_names( + buffer: &AlignedBuffer, + returned: u32, +) -> Result>, ServiceFailure> { + let count = usize::try_from(returned).map_err(|_| invalid_buffer("Invalid service count."))?; + let byte_count = count + .checked_mul(size_of::()) + .ok_or_else(|| invalid_buffer("Invalid service array length."))?; + if byte_count > buffer.len() + || !(buffer.as_ptr() as usize).is_multiple_of(align_of::()) + { + return Err(invalid_buffer( + "Service enumeration returned an invalid array length.", + )); + } + let mut names = Vec::with_capacity(count); + for index in 0..count { + // SAFETY: the checked array byte range contains this element. read + // avoids retaining references into the native buffer. + let record = unsafe { + ptr::read( + buffer + .as_ptr() + .cast::() + .add(index), + ) + }; + let name = copy_wide(record.lpServiceName, buffer, false)?; + names.push(name); + } + Ok(names) +} + +#[derive(Debug)] +struct ParsedBaseConfig { + service_type: u32, + start_type: u32, + error_control: u32, + binary_path: Vec, + load_order_group: Option>, + tag_id: Option, + dependencies: Vec>, + account: Option>, + display_name: Option>, +} + +fn query_base_config(service: SC_HANDLE) -> Result { + let mut required = 0_u32; + // SAFETY: a null output pointer requests the documented buffer size. + let probe = unsafe { QueryServiceConfigW(service, None, 0, &mut required) }; + if let Err(error) = probe + && error_code(&error) != Some(ERROR_INSUFFICIENT_BUFFER) + { + return Err(map_error(error, "query_config")); + } + + for _ in 0..MAX_QUERY_ATTEMPTS { + let requested = checked_query_size(required, size_of::())?; + let mut buffer = AlignedBuffer::new(requested)?; + required = 0; + // SAFETY: the buffer is aligned for QUERY_SERVICE_CONFIGW, initialized, + // and its exact size is supplied. service remains live. + let result = unsafe { + QueryServiceConfigW( + service, + Some(buffer.as_mut_bytes().as_mut_ptr().cast()), + u32::try_from(requested).map_err(|_| resource_limit())?, + &mut required, + ) + }; + match result { + Ok(()) => return Ok(buffer), + Err(error) if error_code(&error) == Some(ERROR_INSUFFICIENT_BUFFER) => continue, + Err(error) => return Err(map_error(error, "query_config")), + } + } + Err(changed_failure("query_config")) +} + +fn query_config2( + service: SC_HANDLE, + level: windows::Win32::System::Services::SERVICE_CONFIG, + stage: &'static str, +) -> Result { + let mut required = 0_u32; + // SAFETY: a null output slice requests the documented buffer size. + let probe = unsafe { QueryServiceConfig2W(service, level, None, &mut required) }; + if let Err(error) = probe + && error_code(&error) != Some(ERROR_INSUFFICIENT_BUFFER) + { + return Err(map_error(error, stage)); + } + for _ in 0..MAX_QUERY_ATTEMPTS { + let requested = checked_query_size(required, 1)?; + let mut buffer = AlignedBuffer::new(requested)?; + required = 0; + // SAFETY: the initialized buffer is live and bounded for this query. + let result = unsafe { + QueryServiceConfig2W(service, level, Some(buffer.as_mut_bytes()), &mut required) + }; + match result { + Ok(()) => return Ok(buffer), + Err(error) if error_code(&error) == Some(ERROR_INSUFFICIENT_BUFFER) => continue, + Err(error) => return Err(map_error(error, stage)), + } + } + Err(changed_failure(stage)) +} + +fn checked_query_size(required: u32, minimum: usize) -> Result { + let requested = usize::try_from(required) + .map_err(|_| resource_limit())? + .max(minimum); + if requested > MAX_QUERY_BUFFER_BYTES { + Err(resource_limit()) + } else { + Ok(requested) + } +} + +fn parse_base_config(buffer: &AlignedBuffer) -> Result { + if buffer.len() < size_of::() + || !(buffer.as_ptr() as usize).is_multiple_of(align_of::()) + { + return Err(invalid_buffer( + "Service configuration returned an invalid structure.", + )); + } + // SAFETY: size and alignment were checked and the structure is copied. + let config = unsafe { ptr::read(buffer.as_ptr().cast::()) }; + Ok(ParsedBaseConfig { + service_type: config.dwServiceType.0, + start_type: config.dwStartType.0, + error_control: config.dwErrorControl.0, + binary_path: copy_wide(config.lpBinaryPathName, buffer, true)?, + load_order_group: copy_optional_wide(config.lpLoadOrderGroup, buffer)?, + tag_id: (config.dwTagId != 0).then_some(config.dwTagId), + dependencies: copy_multi_sz(config.lpDependencies, buffer)?, + account: copy_optional_wide(config.lpServiceStartName, buffer)?, + display_name: copy_optional_wide(config.lpDisplayName, buffer)?, + }) +} + +fn parse_description(buffer: &AlignedBuffer) -> Result>, ServiceFailure> { + if buffer.len() < size_of::() + || !(buffer.as_ptr() as usize).is_multiple_of(align_of::()) + { + return Err(invalid_buffer( + "Service description returned an invalid structure.", + )); + } + // SAFETY: size and alignment were checked and the structure is copied. + let description = unsafe { ptr::read(buffer.as_ptr().cast::()) }; + copy_optional_wide(description.lpDescription, buffer) +} + +fn parse_delayed_auto_start(buffer: &AlignedBuffer) -> Result { + if buffer.len() < size_of::() + || !(buffer.as_ptr() as usize).is_multiple_of(align_of::()) + { + return Err(invalid_buffer( + "Delayed auto-start returned an invalid structure.", + )); + } + // SAFETY: size and alignment were checked and the structure is copied. + let delayed = unsafe { ptr::read(buffer.as_ptr().cast::()) }; + Ok(delayed.fDelayedAutostart.as_bool()) +} + +fn copy_optional_wide( + pointer: PWSTR, + buffer: &AlignedBuffer, +) -> Result>, ServiceFailure> { + if pointer.is_null() { + return Ok(None); + } + let value = copy_wide(pointer, buffer, true)?; + Ok((!value.is_empty()).then_some(value)) +} + +fn copy_wide( + pointer: PWSTR, + buffer: &AlignedBuffer, + allow_empty: bool, +) -> Result, ServiceFailure> { + let start = buffer.as_ptr() as usize; + let end = start + .checked_add(buffer.len()) + .ok_or_else(|| invalid_buffer("Service buffer address overflowed."))?; + let address = pointer.0 as usize; + if pointer.is_null() + || address < start + || address >= end + || !(address - start).is_multiple_of(size_of::()) + { + return Err(invalid_buffer( + "Service data contained an out-of-range string pointer.", + )); + } + let available = (end - address) / size_of::(); + let mut value = Vec::new(); + for index in 0..available { + // SAFETY: index is within the validated buffer range and read_unaligned + // handles any u16 alignment stricter than the byte offset check. + let unit = unsafe { ptr::read_unaligned(pointer.0.add(index)) }; + if unit == 0 { + if value.is_empty() && !allow_empty { + return Err(invalid_buffer( + "Service data contained an empty required string.", + )); + } + return Ok(value); + } + value.push(unit); + } + Err(invalid_buffer( + "Service data contained an unterminated UTF-16 string.", + )) +} + +fn copy_multi_sz(pointer: PWSTR, buffer: &AlignedBuffer) -> Result>, ServiceFailure> { + if pointer.is_null() { + return Ok(Vec::new()); + } + let start = buffer.as_ptr() as usize; + let end = start + .checked_add(buffer.len()) + .ok_or_else(|| invalid_buffer("Service buffer address overflowed."))?; + let address = pointer.0 as usize; + if address < start || address >= end || !(address - start).is_multiple_of(size_of::()) { + return Err(invalid_buffer( + "Service data contained an out-of-range dependency pointer.", + )); + } + let available = (end - address) / size_of::(); + let mut encoded = Vec::new(); + let mut previous_zero = false; + for index in 0..available { + // SAFETY: index is inside the validated native output buffer. + let unit = unsafe { ptr::read_unaligned(pointer.0.add(index)) }; + encoded.push(unit); + if unit == 0 { + if index == 0 || previous_zero { + return parse_multi_sz_units(&encoded); + } + previous_zero = true; + } else { + previous_zero = false; + } + } + Err(invalid_buffer( + "Service dependencies were not double-NUL terminated.", + )) +} + +fn parse_multi_sz_units(encoded: &[u16]) -> Result>, ServiceFailure> { + if encoded == [0] { + return Ok(Vec::new()); + } + if encoded.len() < 2 || !encoded.ends_with(&[0, 0]) { + return Err(invalid_buffer( + "Service dependencies were not double-NUL terminated.", + )); + } + let mut dependencies = Vec::new(); + let mut start = 0_usize; + for index in 0..encoded.len() - 1 { + if encoded[index] == 0 { + if index == start { + return Err(invalid_buffer( + "Service dependencies contained an empty item.", + )); + } + dependencies.push(encoded[start..index].to_vec()); + start = index + 1; + if encoded[index + 1] == 0 { + break; + } + } + } + Ok(dependencies) +} + +fn error_code(error: &WindowsError) -> Option { + WIN32_ERROR::from_error(error) +} + +fn map_error(error: WindowsError, stage: &'static str) -> ServiceFailure { + let code = error_code(&error); + let native_code = code.map(|value| i64::from(value.0)); + if code == Some(ERROR_ACCESS_DENIED) { + failure( + ServiceFailureKind::AccessDenied, + "service_access_denied", + "Service configuration access was denied.", + stage, + native_code, + ) + } else if matches!( + code, + Some(ERROR_SERVICE_DOES_NOT_EXIST | ERROR_SERVICE_MARKED_FOR_DELETE) + ) { + failure( + ServiceFailureKind::DoesNotExist, + "service_vanished_during_scan", + "A service vanished during collection.", + stage, + native_code, + ) + } else { + failure( + ServiceFailureKind::Other, + "service_query_failed", + "A Windows service API query failed.", + stage, + native_code, + ) + } +} + +fn invalid_buffer(message: &'static str) -> ServiceFailure { + failure( + ServiceFailureKind::InvalidData, + "service_invalid_data", + message, + "native_buffer", + None, + ) +} + +fn resource_limit() -> ServiceFailure { + failure( + ServiceFailureKind::ResourceLimit, + "service_resource_limit", + "Service data exceeded a SystemDiff capture budget.", + "native_buffer", + None, + ) +} + +fn changed_failure(stage: &'static str) -> ServiceFailure { + failure( + ServiceFailureKind::Other, + "service_changed_during_scan", + "A service changed during bounded configuration reads.", + stage, + None, + ) +} + +fn failure( + kind: ServiceFailureKind, + code: &'static str, + message: &'static str, + stage: &'static str, + native_code: Option, +) -> ServiceFailure { + ServiceFailure { + kind, + code, + message, + stage, + native_code, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dependency_parser_preserves_order_group_prefix_and_empty_list() { + assert_eq!(parse_multi_sz_units(&[0]).unwrap(), Vec::>::new()); + let encoded: Vec = "RpcSs\0+NetworkProvider\0\0".encode_utf16().collect(); + assert_eq!( + parse_multi_sz_units(&encoded).unwrap(), + vec![ + "RpcSs".encode_utf16().collect::>(), + "+NetworkProvider".encode_utf16().collect::>() + ] + ); + } + + #[test] + fn dependency_parser_rejects_missing_terminator() { + assert!(parse_multi_sz_units(&[b'A' as u16, 0]).is_err()); + } + + #[test] + fn aligned_buffer_rejects_out_of_range_string_pointer() { + let buffer = AlignedBuffer::new(32).unwrap(); + let outside = PWSTR(buffer.as_ptr().wrapping_add(buffer.len()).cast_mut().cast()); + assert!(copy_wide(outside, &buffer, true).is_err()); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index e17216e..03ad8cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,7 +81,7 @@ Each observation separates: - stable fields that participate in default comparison; - volatile fields that are retained only when useful and excluded from default comparison. -Identity is not a `Debug` string, full-JSON hash, localized display name, or executable judgment. Original Windows casing/value text is preserved. Registry startup Collector v1 deliberately uses exact UTF-16 value-name evidence rather than claiming an undocumented durable case-folded token; its known casing-only false-split limitation is versioned and documented in [collectors.md](collectors.md). +Identity is not a `Debug` string, full-JSON hash, localized display name, or executable judgment. Original Windows casing/value text is preserved. Registry startup and Services Collector v1 deliberately use exact UTF-16 name evidence rather than claiming undocumented durable case-folded tokens; their known casing-only false-split limitations are versioned and documented in [collectors.md](collectors.md). Registry observations keep the native type code separate from a tagged typed decoding result. String, expandable-string, multi-string, DWORD, and QWORD interpretations therefore do not force unknown, binary, or malformed Registry data through a text-only model. A full-content SHA-256 keeps undecoded or truncated values comparable. Optional raw prefixes are limited to 4 KiB and use validated lowercase hex with captured/original sizes and truncation metadata; Collectors do not duplicate raw bytes when decoded evidence is sufficient. @@ -114,7 +114,7 @@ The human renderer consumes a `DiffDocument`. The technical renderer also receiv Windows collection uses Unicode platform APIs through narrowly feature-gated `windows-rs` bindings: - Run/RunOnce (implemented): Registry APIs with explicit WOW64 views where applicable. -- Services (planned): Service Control Manager enumeration and configuration query APIs. +- Services (implemented): query-only Service Control Manager enumeration plus atomic base/description/delayed configuration reads, with current-token best-effort coverage. - Scheduled Tasks (planned): Task Scheduler 2.0 COM interfaces with recursive folder traversal. Command output from `reg.exe`, `sc.exe`, `schtasks.exe`, PowerShell, or WMI is not a data contract and will not be parsed. Detailed coverage and API references live in [collectors.md](collectors.md). diff --git a/docs/collectors.md b/docs/collectors.md index 4a664e6..5e4e3be 100644 --- a/docs/collectors.md +++ b/docs/collectors.md @@ -45,7 +45,7 @@ Official references: [Run and RunOnce](https://learn.microsoft.com/windows/win32 ## Windows services -Planned ID: `windows.services` +Implemented ID/version: `windows.services` v1 v0.1 covers Win32 service configuration and explicitly excludes drivers. @@ -54,11 +54,17 @@ Implementation source: - `OpenSCManagerW` and `EnumServicesStatusExW` for enumeration. - `OpenServiceW`, `QueryServiceConfigW`, and narrowly selected `QueryServiceConfig2W` levels for configuration. - Service name is identity; display name is evidence, not identity. -- Stable configuration includes raw type/start/error-control values, binary path, account, dependencies, load group/tag, delayed-auto-start, and description when readable. +- Stable configuration includes raw type/start/error-control values, unexpanded binary path, account, dependencies, load group/tag, delayed-auto-start, and description. - PID, checkpoint, wait hint, and transient run status do not participate in the default configuration diff. - A service that vanishes between enumeration and query produces an item diagnostic rather than failing the Collector. -`EnumServicesStatusExW` can omit services the caller cannot query. Non-elevated coverage is therefore current-token/best-effort even if enumeration returns success. Per-user services with `_LUID` suffixes retain their full names; explanation rules may reduce noise, but collection never merges them. +`EnumServicesStatusExW` can silently omit services for which the caller lacks `SERVICE_QUERY_STATUS`. The single `current_token.win32` scope is therefore always `partial`/best-effort even when enumeration succeeds. A one-sided absence is Inconclusive, not Removed. Every emitted observation is atomic: base config, description, and delayed-auto-start were all read and strictly decoded; an unreadable/malformed item is omitted with a diagnostic while complete siblings remain. `None` consequently means known configured absence, not query failure. + +Enumeration requests only `SC_MANAGER_ENUMERATE_SERVICE`; each service is opened only with `SERVICE_QUERY_CONFIG`. `SERVICE_WIN32` includes own-process/share-process services and their documented modifiers while excluding native driver-only types. Per-user services retain their full `_LUID` suffix. Dependencies retain API order, casing, and `+` group prefixes; no command parsing, environment expansion, executable resolution, hashing, signature check, or risk inference occurs. + +Collector v1 identity is domain-separated SHA-256 over the exact service-name UTF-16 units and length. Service lookup is case-insensitive, but Microsoft exposes no documented durable cross-platform canonical token; v1 therefore documents the conservative possibility of a casing-only false split rather than applying an unverified Unicode/NLS fold. Display name and transient status/PID are never identity. + +SystemDiff budgets are 4,096 retained services, 32 KiB of retained UTF-16 text per service, and 16 MiB across the Collector. Enumeration pages are bounded to 64 steps; native enumeration/query buffers follow the documented 256 KiB/8 KiB API ceilings. These are capture budgets, not general Windows Registry or SCM platform limits. Configuration changes are handled with at most three full reads looking for two consecutive equal bundles; a vanishing or unstable service produces a diagnostic. Official references: [EnumServicesStatusExW](https://learn.microsoft.com/windows/win32/api/winsvc/nf-winsvc-enumservicesstatusexw), [QUERY_SERVICE_CONFIGW](https://learn.microsoft.com/windows/win32/api/winsvc/ns-winsvc-query_service_configw), [service access rights](https://learn.microsoft.com/windows/win32/services/service-security-and-access-rights), [per-user services](https://learn.microsoft.com/windows/application-management/per-user-services-in-windows). diff --git a/docs/data-format.md b/docs/data-format.md index 38c749f..edca2f8 100644 --- a/docs/data-format.md +++ b/docs/data-format.md @@ -84,6 +84,10 @@ Collectors own canonicalization and version it through their Collector version. For Registry startup entries, Collector v1 computes a domain-separated SHA-256 over the exact value-name UTF-16 code units and their length. Prefixes and empty names are not stripped or normalized. Windows Registry value lookup itself is case-insensitive, but Microsoft does not provide a documented persistent canonical representation that can be generated independently in two Snapshots and compared cross-platform. Exact-code-unit identity is therefore a conservative pre-v0.1 limitation: it prevents false merges but could expose a casing-only logical update as Removed + Added if enumerated casing changes. This is not a permanent claim about Registry identity; changing the algorithm requires a new Collector version and regression fixtures. +For Windows services, Collector v1 uses the same versioned/domain-separated exact-UTF-16 strategy over the service name; display name, PID, state, and configuration are not identity. The service artifact now requires `load_order_group` and `tag_id` fields in addition to raw service/start/error values, binary path, account, ordered dependencies, delayed-auto-start, and description. This is a deliberate pre-v0.1 correction to the draft wire shape; old draft Service objects missing the two fields are rejected, while explicit `null` means the complete query established configured absence. Query failure never becomes `null`: the whole item is omitted and coverage remains partial. + +Services v1 retains exact dependency order/casing and documented `+` group prefixes. Its real `current_token.win32` scope is always partial because SCM enumeration may silently omit status-inaccessible services. A same-identity item observed on both sides can still be Modified, but one-sided absence is Inconclusive. Focused synthetic fixtures may use complete coverage solely to regression-test the generic Added/Removed semantics. + ## Diff semantics Output order is stable. A change contains a deterministic document-local opaque change ID, artifact key, and one of: diff --git a/docs/roadmap.md b/docs/roadmap.md index cb593fb..80f4394 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,11 +25,11 @@ systemdiff diff before.json after.json Required scope: - Registry Run/RunOnce entries. **Implemented in the development CLI; pre-release validation continues.** -- Windows services configuration. +- Windows services configuration. **Implemented in the development CLI with conservative current-token partial coverage.** - Scheduled Tasks 2.0 configuration. - Versioned snapshot and diff JSON. - Human-readable terminal report. -- An unsigned, expiring Windows x64 CI Developer Preview that proves the existing CLI can run from a downloaded portable package without Cargo. **In progress; this is not an official release.** +- An unsigned, expiring Windows x64 CI Developer Preview that proves the existing CLI can run from a downloaded portable package without Cargo. **Implemented; this is not an official release.** - Independent collector failures and clear privilege/coverage reporting. - Deterministic fixtures and snapshot-to-diff integration tests that do not need administrator privileges. diff --git a/docs/threat-model.md b/docs/threat-model.md index a4e71dd..e276523 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -18,7 +18,7 @@ Out of scope: - defending evidence integrity after kernel compromise; - credential dumping, exploitation, persistence creation, AV/EDR bypass, or remediation features, which are prohibited by `docs/product-principles.md` and `AGENTS.md`; - cloud services, multi-tenancy, authentication, and telemetry, which do not exist in the MVP; -- a final assessment of the still-unimplemented Services and Scheduled Tasks Collectors. +- a final assessment of the still-unimplemented Scheduled Tasks Collector. Assumptions: @@ -52,7 +52,7 @@ Open questions that may change rankings: ### Data flows and trust boundaries -- Windows OS -> Windows collectors: the implemented Registry adapter reads native buffers through current-token Win32 access; future service/task adapters will add SCM and COM. Safe wrappers validate lengths, encodings, numeric Win32 outcomes, resource budgets, and concurrent mutation. +- Windows OS -> Windows collectors: the implemented Registry and SCM adapters read native buffers through current-token Win32 access; a future task adapter will add COM. Safe wrappers validate lengths, pointers, encodings, numeric Win32 outcomes, resource budgets, and concurrent mutation. - Snapshot files -> CLI/core parser: attacker-controlled local JSON via file I/O; a 64 MiB bounded read and header-first schema route precede full Snapshot construction, while typed validation and identity uniqueness protect later processing. Finer object, string, nesting, and count limits remain future hardening. - Core evidence -> diff/rules: typed in-process values; compatibility, coverage, deterministic identity, and no evidence execution are the guarantees. - Diff/findings -> report files/terminal: privacy-sensitive local output; destination choice is user-controlled, and future sanitization must be explicit. @@ -136,9 +136,9 @@ flowchart LR | Threat ID | Threat source | Prerequisites | Threat action | Impact | Impacted assets | Existing controls (evidence) | Gaps | Recommended mitigations | Detection ideas | Likelihood | Impact severity | Priority | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| TM-001 | Local changed software | Can race, deny, or influence a collected scope | Cause incomplete evidence to appear complete | False Added/Removed and incorrect findings | Diff and coverage integrity | Registry scopes have bounded retries, before/after metadata checks, resource/permission diagnostics, and no-false-removal coverage semantics (`docs/adr/0004-collector-failure-and-coverage.md`) | Registry enumeration is best-effort, not atomic; later Collectors remain unimplemented | Preserve per-scope coverage, keep bounded native diagnostics, and add Collector-specific race fixtures | Count coverage changes and surface prominent report warnings | Medium | High | High | +| TM-001 | Local changed software | Can race, deny, or influence a collected scope | Cause incomplete evidence to appear complete | False Added/Removed and incorrect findings | Diff and coverage integrity | Registry has bounded retries/metadata checks; Services uses bounded consecutive config reads and always-partial current-token coverage; both preserve no-false-removal semantics (`docs/adr/0004-collector-failure-and-coverage.md`) | Collection is best-effort, not atomic; Tasks remains unimplemented | Preserve per-scope coverage and bounded native diagnostics; add Collector-specific race fixtures | Count coverage changes and surface prominent report warnings | Medium | High | High | | TM-002 | Snapshot provider | Can supply arbitrary local JSON | Exhaust memory/CPU or exploit parser assumptions | CLI denial of service; misleading evidence | Availability and diff integrity | 64 MiB bounded file read, header-first version routing, typed schema, and duplicate rejection (`docs/data-format.md`) | No object-count, string-size, nesting, or parser-fuzz limits yet | Add finer structural limits only from measured need; fuzz parsers later | Structured parse failure codes; resource-limit tests | Medium | Medium | Medium | -| TM-003 | Windows data/API edge case | Collector handles raw buffers/COM values | Trigger unsafe length, lifetime, or encoding bug | Crash, memory corruption, corrupted evidence | Token, evidence, availability | Unsafe is isolated to a narrow `windows-rs` Registry adapter with checked units/lengths, initialized buffers, bounded growth, RAII handles, strict pure decoders, and full native-byte hashing (`docs/architecture.md`) | No fuzzing; Services/Tasks native adapters not implemented | Keep unsafe blocks minimal and reviewed; fuzz pure decoders; apply equivalent RAII/bounds discipline to future SCM/COM work | Windows regression fixtures and sanitizer/fuzz jobs where practical | Medium | High | High | +| TM-003 | Windows data/API edge case | Collector handles raw buffers/COM values | Trigger unsafe length, lifetime, or encoding bug | Crash, memory corruption, corrupted evidence | Token, evidence, availability | Unsafe is isolated to narrow `windows-rs` Registry/SCM adapters with checked units, ranges, pointers, initialized aligned buffers, bounded growth, RAII handles, and strict pure normalization (`docs/architecture.md`) | No fuzzing; Tasks COM adapter not implemented | Keep unsafe blocks minimal and independently reviewed; fuzz pure decoders; apply equivalent discipline to future COM work | Windows regression fixtures and sanitizer/fuzz jobs where practical | Medium | High | High | | TM-004 | User/workflow mistake | Real report is shared publicly | Publish sensitive raw evidence | Lasting privacy disclosure | Snapshot/report confidentiality | Sensitive-by-default and redaction metadata (`docs/product-principles.md`) | Sanitizer absent | Blocking share warning in UI, documented manual review, policy-versioned pure sanitizer, synthetic issue fixtures | Scan project issues for accidental reports; sanitizer golden tests | High | High | High | | TM-005 | Future compromised WebView | Desktop exposes broad command/capability | Invoke native execution/write or read excess data | Privilege misuse and boundary violation | Token, host integrity, evidence confidentiality | Proposed narrow IPC (`docs/adr/0003-desktop-stack.md`) | Desktop not yet threat-tested | Bundled content, restrictive CSP, explicit commands, no generic shell/fs/http plugins, command authorization tests | Log command IDs without sensitive payloads; capability review in CI | Low pre-v0.2 | High | Medium | | TM-006 | Rule author or malformed evidence | Rule sees ambiguous command/path data | Overstate heuristic or detach finding from evidence | Misleading/fearmongering output | Finding integrity and user trust | Evidence-before-judgment principle; findings reference changes (`docs/architecture.md`) | No real rule corpus/reviewer rubric | Stable reason IDs, calibrated classifications, explanation keys, counterexample fixtures, independent review | Golden finding snapshots and rule precision review | Medium | Medium | Medium | diff --git a/fixtures/snapshots/after-v1.json b/fixtures/snapshots/after-v1.json index 1af31b4..c039288 100644 --- a/fixtures/snapshots/after-v1.json +++ b/fixtures/snapshots/after-v1.json @@ -50,14 +50,22 @@ { "id": "windows.services", "version": 1, - "status": "complete", + "status": "partial", "coverage": [ { - "scope_id": "machine.win32", - "status": "complete" + "scope_id": "current_token.win32", + "status": "partial" } ], - "diagnostics": [] + "diagnostics": [ + { + "code": "service_visibility_best_effort", + "message": "Synthetic fixture models current-token service visibility.", + "stage": "enumerate", + "native_code": null, + "scope_id": "current_token.win32" + } + ] }, { "id": "windows.scheduled_tasks", @@ -148,8 +156,8 @@ { "collector_id": "windows.services", "collector_version": 1, - "scope_id": "machine.win32", - "canonical_id": "newservice", + "scope_id": "current_token.win32", + "canonical_id": "d57075224a1a234f0ec190bff86b12a3308908b4570c02d57d2682cff439f6db", "artifact": { "kind": "windows_service", "evidence": { @@ -161,6 +169,8 @@ "binary_path": "C:\\Program Files\\Example\\service.exe --service", "account": "LocalSystem", "dependencies": ["RpcSs"], + "load_order_group": null, + "tag_id": null, "delayed_auto_start": true, "description": "Synthetic service added after the change." } diff --git a/fixtures/snapshots/before-v1.json b/fixtures/snapshots/before-v1.json index 6bc6eb9..4e272da 100644 --- a/fixtures/snapshots/before-v1.json +++ b/fixtures/snapshots/before-v1.json @@ -50,14 +50,22 @@ { "id": "windows.services", "version": 1, - "status": "complete", + "status": "partial", "coverage": [ { - "scope_id": "machine.win32", - "status": "complete" + "scope_id": "current_token.win32", + "status": "partial" } ], - "diagnostics": [] + "diagnostics": [ + { + "code": "service_visibility_best_effort", + "message": "Synthetic fixture models current-token service visibility.", + "stage": "enumerate", + "native_code": null, + "scope_id": "current_token.win32" + } + ] }, { "id": "windows.scheduled_tasks", @@ -118,8 +126,8 @@ { "collector_id": "windows.services", "collector_version": 1, - "scope_id": "machine.win32", - "canonical_id": "legacyservice", + "scope_id": "current_token.win32", + "canonical_id": "46561d2f6078ffa19958353b4dd219719a0316486a319e9b30f5da83feee9a94", "artifact": { "kind": "windows_service", "evidence": { @@ -131,6 +139,8 @@ "binary_path": "C:\\Program Files\\Legacy\\legacy.exe", "account": "LocalSystem", "dependencies": [], + "load_order_group": null, + "tag_id": null, "delayed_auto_start": false, "description": "Synthetic service present before the change." } diff --git a/fixtures/snapshots/services-added-after-v1.json b/fixtures/snapshots/services-added-after-v1.json new file mode 100644 index 0000000..f83a544 --- /dev/null +++ b/fixtures/snapshots/services-added-after-v1.json @@ -0,0 +1,61 @@ +{ + "document_type": "systemdiff.snapshot", + "schema_version": 1, + "systemdiff_version": "0.0.0-fixture", + "captured_at": "2026-08-11T00:05:00Z", + "host": { + "windows_version": "Windows fixture", + "windows_build": "fixture", + "architecture": "x86_64" + }, + "privilege": "standard_user", + "enabled_collectors": [ + "windows.services" + ], + "collectors": [ + { + "id": "windows.services", + "version": 1, + "status": "complete", + "coverage": [ + { + "scope_id": "current_token.win32", + "status": "complete" + } + ], + "diagnostics": [] + } + ], + "redaction": { + "status": "unredacted", + "policy": null + }, + "observations": [ + { + "collector_id": "windows.services", + "collector_version": 1, + "scope_id": "current_token.win32", + "canonical_id": "1f8fb1c080b80b63fc87e55671c51dda7a3c16cf4a3c7dcbe7224c75ef1000c8", + "artifact": { + "kind": "windows_service", + "evidence": { + "service_name": "ExampleUpdaterService_1a2b3", + "display_name": "Example Update Service", + "service_type": 16, + "start_type": 3, + "error_control": 1, + "binary_path": "%ProgramFiles%\\Example\\example-service.exe --service", + "account": "LocalSystem", + "dependencies": [ + "RpcSs", + "+NetworkProvider" + ], + "load_order_group": null, + "tag_id": null, + "delayed_auto_start": false, + "description": "Synthetic service fixture for deterministic tests." + } + } + } + ] +} diff --git a/fixtures/snapshots/services-added-before-v1.json b/fixtures/snapshots/services-added-before-v1.json new file mode 100644 index 0000000..166358e --- /dev/null +++ b/fixtures/snapshots/services-added-before-v1.json @@ -0,0 +1,34 @@ +{ + "document_type": "systemdiff.snapshot", + "schema_version": 1, + "systemdiff_version": "0.0.0-fixture", + "captured_at": "2026-08-11T00:00:00Z", + "host": { + "windows_version": "Windows fixture", + "windows_build": "fixture", + "architecture": "x86_64" + }, + "privilege": "standard_user", + "enabled_collectors": [ + "windows.services" + ], + "collectors": [ + { + "id": "windows.services", + "version": 1, + "status": "complete", + "coverage": [ + { + "scope_id": "current_token.win32", + "status": "complete" + } + ], + "diagnostics": [] + } + ], + "redaction": { + "status": "unredacted", + "policy": null + }, + "observations": [] +} diff --git a/packaging/windows/QUICKSTART.md b/packaging/windows/QUICKSTART.md index 8489b94..cd9b561 100644 --- a/packaging/windows/QUICKSTART.md +++ b/packaging/windows/QUICKSTART.md @@ -1,6 +1,6 @@ # SystemDiff Windows x64 Developer Preview -This is an **unsigned, pre-release Developer Preview**, not an official SystemDiff release. It currently observes only the documented Windows Registry `Run` and `RunOnce` startup locations. Windows Services and Scheduled Tasks are not implemented. +This is an **unsigned, pre-release Developer Preview**, not an official SystemDiff release. It observes documented Windows Registry `Run`/`RunOnce` startup locations and current-token-visible Windows service configuration. Scheduled Tasks are not implemented. SystemDiff runs locally, requires no account, includes no telemetry, and its product behavior is read-only. This package targets Windows x64. The current minimum collection platform is Windows 10 version 1709 or Windows Server 2016 version 1709. @@ -13,7 +13,7 @@ Open PowerShell in the extracted directory: .\systemdiff.exe collectors ``` -Administrator privileges are not required. If Windows limits access to a Registry scope, SystemDiff reports the coverage gap instead of silently treating missing evidence as a removal. +Administrator privileges are not required. Registry permission gaps are reported explicitly. Windows can silently omit services the current token cannot query, so Services v1 always reports best-effort partial coverage and never treats a missing service as a confirmed removal. ## Compare before and after @@ -30,7 +30,7 @@ Use `diff --technical` for exact text evidence or `diff --json` for the versione ## Privacy -Snapshots and all report modes are unredacted. They may contain command strings, usernames in paths, hashes, and other host details. Review every file before sharing it, and never attach an unreviewed real Snapshot or report to a public Issue. +Snapshots and all report modes are unredacted. They may contain service accounts, paths and arguments, descriptions, command strings, usernames, hashes, and other host details. Review every file before sharing it, and never attach an unreviewed real Snapshot or report to a public Issue. ## Trust and removal diff --git a/scripts/verify-windows-preview.ps1 b/scripts/verify-windows-preview.ps1 index 76dae95..30eadc2 100644 --- a/scripts/verify-windows-preview.ps1 +++ b/scripts/verify-windows-preview.ps1 @@ -351,6 +351,7 @@ try { $collectors = Invoke-PreviewCommand -Executable $executable -Arguments @('collectors') Assert-Condition ($collectors.IndexOf('windows.registry.startup v1: Implemented', [System.StringComparison]::Ordinal) -ge 0) 'Collector output does not report the Registry startup Collector as implemented.' + Assert-Condition ($collectors.IndexOf('windows.services v1: Implemented', [System.StringComparison]::Ordinal) -ge 0) 'Collector output does not report the Windows Services Collector as implemented.' $before = Join-Path $fixtures 'snapshots\registry-before-v1.json' $after = Join-Path $fixtures 'snapshots\registry-after-v1.json' @@ -375,10 +376,18 @@ try { $null = Invoke-PreviewCommand -Executable $executable -Arguments @('snapshot', '-o', $snapshotPath) Assert-Condition (Test-Path -LiteralPath $snapshotPath -PathType Leaf) 'Packaged executable did not create a Snapshot.' $snapshotText = [System.IO.File]::ReadAllText($snapshotPath, [System.Text.Encoding]::UTF8) - Assert-Condition ($snapshotText -match '"document_type"\s*:\s*"systemdiff\.snapshot"') 'Snapshot document type is not canonical.' - Assert-Condition ($snapshotText -match '"schema_version"\s*:\s*1\s*,') 'Snapshot schema version is not 1.' - Assert-Condition ($snapshotText -match '"enabled_collectors"\s*:\s*\[\s*"windows\.registry\.startup"\s*\]') 'Snapshot does not enable the Registry startup Collector.' - Assert-Condition ($snapshotText -match '"collectors"\s*:\s*\[\s*\{\s*"id"\s*:\s*"windows\.registry\.startup"') 'Snapshot does not contain the Registry startup Collector run.' + $snapshotDocument = $snapshotText | ConvertFrom-Json + Assert-Condition ($snapshotDocument.document_type -ceq 'systemdiff.snapshot') 'Snapshot document type is not canonical.' + Assert-Condition ($snapshotDocument.schema_version -eq 1) 'Snapshot schema version is not 1.' + Assert-Condition (@($snapshotDocument.enabled_collectors).Count -eq 2) 'Snapshot does not enable exactly both implemented Collectors.' + Assert-Condition (@($snapshotDocument.enabled_collectors | Where-Object { $_ -ceq 'windows.registry.startup' }).Count -eq 1) 'Snapshot does not enable the Registry startup Collector.' + Assert-Condition (@($snapshotDocument.enabled_collectors | Where-Object { $_ -ceq 'windows.services' }).Count -eq 1) 'Snapshot does not enable the Windows Services Collector.' + $registryRun = @($snapshotDocument.collectors | Where-Object { $_.id -ceq 'windows.registry.startup' }) + $servicesRun = @($snapshotDocument.collectors | Where-Object { $_.id -ceq 'windows.services' }) + Assert-Condition ($registryRun.Count -eq 1) 'Snapshot does not contain exactly one Registry startup Collector run.' + Assert-Condition ($servicesRun.Count -eq 1) 'Snapshot does not contain exactly one Windows Services Collector run.' + Assert-Condition ($servicesRun[0].status -ceq 'partial') 'Services Collector did not report conservative partial coverage.' + Assert-Condition (@($servicesRun[0].coverage | Where-Object { $_.scope_id -ceq 'current_token.win32' -and $_.status -ceq 'partial' }).Count -eq 1) 'Snapshot does not report the Services current-token partial scope.' $null = Invoke-PreviewCommand -Executable $executable -Arguments @('diff', '--json', $snapshotPath, $snapshotPath) Write-Output 'Artifact-only smoke: --help passed' From 384843a5678aed040630add782574fbd35eaa4d3 Mon Sep 17 00:00:00 2001 From: Xiaoju Date: Thu, 13 Aug 2026 16:54:58 +0800 Subject: [PATCH 2/3] fix(windows): keep service failures cross-platform clean --- crates/systemdiff-windows/src/services.rs | 1 - crates/systemdiff-windows/src/win32_services.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/systemdiff-windows/src/services.rs b/crates/systemdiff-windows/src/services.rs index a5f27aa..12e9146 100644 --- a/crates/systemdiff-windows/src/services.rs +++ b/crates/systemdiff-windows/src/services.rs @@ -66,7 +66,6 @@ pub(crate) struct ServiceEnumeration { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ServiceFailureKind { AccessDenied, - DoesNotExist, InvalidData, ResourceLimit, Other, diff --git a/crates/systemdiff-windows/src/win32_services.rs b/crates/systemdiff-windows/src/win32_services.rs index 084365b..40faa48 100644 --- a/crates/systemdiff-windows/src/win32_services.rs +++ b/crates/systemdiff-windows/src/win32_services.rs @@ -599,7 +599,7 @@ fn map_error(error: WindowsError, stage: &'static str) -> ServiceFailure { Some(ERROR_SERVICE_DOES_NOT_EXIST | ERROR_SERVICE_MARKED_FOR_DELETE) ) { failure( - ServiceFailureKind::DoesNotExist, + ServiceFailureKind::Other, "service_vanished_during_scan", "A service vanished during collection.", stage, From 3e283dad199dc1d2d771fb270cb66f475b0ef1aa Mon Sep 17 00:00:00 2001 From: Xiaoju Date: Thu, 13 Aug 2026 17:00:19 +0800 Subject: [PATCH 3/3] docs: record Services PR readiness --- .agent/PROJECT_STATE.md | 4 ++-- .agent/plans/windows-services-collector.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agent/PROJECT_STATE.md b/.agent/PROJECT_STATE.md index effbf00..aaffa18 100644 --- a/.agent/PROJECT_STATE.md +++ b/.agent/PROJECT_STATE.md @@ -4,7 +4,7 @@ Last updated: 2026-08-13 ## Current phase -The public foundation, Registry startup vertical slice, human-readable Diff, and portable Windows x64 Developer Preview are on `main`. The current `feat/windows-services` work for Issue #11 adds the second real Collector while preserving the read-only and no-false-removal boundaries. v0.1 remains incomplete. +The public foundation, Registry startup vertical slice, human-readable Diff, and portable Windows x64 Developer Preview are on `main`. PR #12 on `feat/windows-services` implements Issue #11's second real Collector while preserving the read-only and no-false-removal boundaries. Implementation, independent review, and local/real-Windows validation are complete; merge remains a maintainer decision. v0.1 remains incomplete. ## Implemented components @@ -50,7 +50,7 @@ The public foundation, Registry startup vertical slice, human-readable Diff, and ## Next milestone -Finish implementation, independent review, real read-only Windows validation, remote CI, and downloaded-artifact verification for Issue #11 without merging automatically. Scheduled Tasks remains intentionally unstarted; a signed, permanent public download remains future release work. +Evaluate and merge PR #12 for Issue #11 when its authoritative GitHub checks remain green. Scheduled Tasks remains intentionally unstarted; a signed, permanent public download remains future release work. ## Major unresolved questions diff --git a/.agent/plans/windows-services-collector.md b/.agent/plans/windows-services-collector.md index c0174f1..6cfaedf 100644 --- a/.agent/plans/windows-services-collector.md +++ b/.agent/plans/windows-services-collector.md @@ -144,7 +144,7 @@ The Collector and renderer can be reverted without writing system state. The wir - [x] 2026-08-13: implemented the pure Collector and query-only native SCM adapter with RAII handles, bounded native buffers/pagination, strict UTF-16, atomic observations, and deterministic selection. - [x] 2026-08-13: integrated both Collectors into Snapshot capture, Service report rendering, focused fixtures/tests, portable verification, and public/internal docs. - [x] 2026-08-13: completed local, real-Windows, portable, and independent-review validation; all review findings were resolved. -- [ ] Commit, push, open PR, and observe final remote CI. +- [x] 2026-08-13: committed, pushed, and opened PR #12 linked to Issue #11. Authoritative remote validation remains recorded on the PR rather than creating a circular post-CI repository edit. ## Discoveries @@ -179,4 +179,4 @@ Local implementation validation on 2026-08-13: - Final local portable package verification passed after removing Cargo/Rust/linker tools from `PATH`: AMD64, `asInvoker`, `uiAccess=false`, unsigned, no delayed imports, reviewed imports `advapi32.dll`, `api-ms-win-core-synch-l1-2-0.dll`, `KERNEL32.dll`, and `ntdll.dll`. EXE size was 1,860,608 bytes; ZIP size was 740,539 bytes. Exact outer/inner allowlists and checksum passed, and the packaged binary captured both Collectors. - `git diff --check`: passed. Repository secret/machine-path scan found no new credential or committed real-host evidence. - Independent reviewer final result: High 0 / Medium 0 / Low 0. The review-driven fixes made per-service failure diagnostics distinguishable without exposing service names and rendered delayed-auto-start changes independently of start type. -- Final remote CI/artifact results remain pending and will be appended without inference. +- Remote Windows/Ubuntu CI, package verification, upload, and fresh-runner download verification are authoritative GitHub PR/run state and are not copied into this repository after each HEAD change.