From 9e295987733ee88d032c3f3ca475a8aac954b74e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:40:19 +0000 Subject: [PATCH 1/3] Initial plan From ad9a251091f1da36de717e3ec5474d038651d03a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:59:44 +0000 Subject: [PATCH 2/3] feat(intune): implement Windows Autopilot evidence parser (issue #362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cmtraceopen_parser::intune::enrollment::windows::autopilot with: - models.rs: typed snapshot contract (AutopilotSnapshot, outcome, phase, ESPLinkage) - sources.rs: input contract, explicit schema detection, capture metadata validation - normalize.rs: classification of validated Autopilot event IDs to typed signals - reducer.rs: pure fold from evidence → immutable snapshot with all audit fixes - rules.rs: evidence-backed findings with next-artifact pointers - redaction.rs: deterministic privacy projection with unified redaction token namespace - 15-scenario fixture matrix (all required by issue spec) - Integration test suite: intune_windows_autopilot.rs (25 tests) Audit fixes applied over recovery branch baseline: - Add event_version field to NormalizedWindowsEvent and AutopilotObservation - Add source_kind_tag/rotation_index/fragment_index to AutopilotSourceInput - Gate outcome reduction on is_assessable (access_state==Available, parse_state==Parsed) - reduce_esp_linkage: only emit TimeOnlyCandidate with UTC time basis + actual overlap - Disjoint keys matching >1 ESP session → Conflicting, not high-confidence Linked - Unify redaction token namespace to single VALUE_KIND=redacted everywhere - Tighten is_token() to only accept [redacted:<16hex>] format produced by stable_token Passes: cargo test --locked -p cmtraceopen-parser (938 total) cargo clippy --locked -p cmtraceopen-parser --all-targets -- -D warnings cargo check --locked -p cmtraceopen-parser --target wasm32-unknown-unknown cargo fmt --check --all --- Cargo.lock | 2 +- .../src/collector/profile.rs | 5 +- .../cmtraceopen-parser/src/esp/redaction.rs | 3 +- crates/cmtraceopen-parser/src/esp/reducer.rs | 5 +- crates/cmtraceopen-parser/src/esp/timeline.rs | 5 +- .../enrollment/windows/autopilot/mod.rs | 120 +- .../enrollment/windows/autopilot/models.rs | 560 ++++++ .../enrollment/windows/autopilot/normalize.rs | 345 ++++ .../enrollment/windows/autopilot/redaction.rs | 337 ++++ .../enrollment/windows/autopilot/reducer.rs | 1502 +++++++++++++++++ .../enrollment/windows/autopilot/rules.rs | 761 +++++++++ .../enrollment/windows/autopilot/sources.rs | 528 ++++++ .../src/intune/normalized.rs | 6 +- .../company_portal/package_state/mod.rs | 13 +- .../tests/esp_diagnostics.rs | 31 +- .../current/autopilot-events.json | 313 ++++ .../current/autopilot-report.json | 129 ++ .../expected.json | 84 + .../manifest.json | 64 + .../current/autopilot-events.json | 57 + .../current/esp-sessions.json | 34 + .../current/autopilot-report.json | 39 + .../expected.json | 83 + .../manifest.json | 64 + .../current/autopilot-events.json | 149 ++ .../current/esp-sessions.json | 20 + .../current/autopilot-report.json | 125 ++ .../expected.json | 122 ++ .../manifest.json | 64 + .../current/autopilot-events.json | 132 ++ .../current/autopilot-report.json | 67 + .../expected.json | 68 + .../manifest.json | 48 + .../current/autopilot-events.json | 133 ++ .../incomplete-event-channel/expected.json | 105 ++ .../incomplete-event-channel/manifest.json | 32 + .../current/autopilot-events.json | 137 ++ .../current/esp-sessions.json | 20 + .../current/autopilot-report.json | 76 + .../autopilot/invalid-timezone/expected.json | 98 ++ .../autopilot/invalid-timezone/manifest.json | 64 + .../current/autopilot-events.json | 95 ++ .../current/autopilot-report.json | 4 + .../malformed-report-section/expected.json | 57 + .../malformed-report-section/manifest.json | 48 + .../current/autopilot-events.json | 99 ++ .../current/esp-sessions.json | 20 + .../current/autopilot-report.json | 72 + .../expected.json | 105 ++ .../manifest.json | 64 + .../current/autopilot-events.json | 132 ++ .../current/autopilot-report.json | 87 + .../expected.json | 60 + .../manifest.json | 48 + .../current/autopilot-events.json | 132 ++ .../no-profile-candidate/expected.json | 62 + .../no-profile-candidate/manifest.json | 32 + .../current/autopilot-events.json | 183 ++ .../current/autopilot-report.json | 59 + .../profile-application-failure/expected.json | 71 + .../profile-application-failure/manifest.json | 48 + .../current/autopilot-events.json | 132 ++ .../current/autopilot-report.json | 96 ++ .../profile-retrieval-failure/expected.json | 71 + .../profile-retrieval-failure/manifest.json | 48 + .../current/autopilot-events.json | 90 + .../current/self-deploying-contract.json | 5 + .../expected.json | 57 + .../manifest.json | 48 + .../current/autopilot-events.json | 313 ++++ .../current/autopilot-report.json | 129 ++ .../expected.json | 81 + .../manifest.json | 48 + .../current/autopilot-events.json | 313 ++++ .../current/esp-sessions.json | 20 + .../current/autopilot-report.json | 129 ++ .../expected.json | 124 ++ .../manifest.json | 64 + .../tests/intune_skeleton_contract.rs | 33 +- .../tests/intune_windows_autopilot.rs | 647 +++++++ .../cmtraceopen-parser/tests/support/mod.rs | 30 +- src-tauri/src/commands/elevation.rs | 20 +- src-tauri/src/commands/file_ops.rs | 10 +- src-tauri/src/commands/jamf.rs | 4 +- src-tauri/src/commands/mod.rs | 2 +- src-tauri/src/commands/recent_entries.rs | 5 +- src-tauri/src/commands/system_preferences.rs | 3 +- src-tauri/src/elevation/mod.rs | 12 +- src-tauri/src/error.rs | 10 +- src-tauri/src/esp/process.rs | 3 +- src-tauri/src/esp/registry.rs | 6 +- src-tauri/src/esp/system.rs | 6 +- src-tauri/src/graph_api/esp.rs | 16 +- src-tauri/src/graph_api/models.rs | 5 +- src-tauri/src/intune/evtx_parser.rs | 3 +- src-tauri/src/jamf/connect.rs | 4 +- src-tauri/src/jamf/detect.rs | 31 +- src-tauri/src/jamf/mod.rs | 10 +- src-tauri/src/jamf/models.rs | 4 +- src-tauri/src/jamf/paths.rs | 5 +- src-tauri/src/jamf/policy_log.rs | 6 +- src-tauri/src/lib.rs | 4 +- src-tauri/src/macos_diag/unified_log.rs | 4 +- src-tauri/src/menu.rs | 13 +- src-tauri/src/sysmon/evtx_parser.rs | 5 +- src-tauri/tests/esp_diagnostics_sources.rs | 17 +- src-tauri/tests/jamf_environment.rs | 5 +- src-tauri/tests/jamf_ipc_contract.rs | 5 +- src-tauri/tests/jamf_known_sources.rs | 15 +- src-tauri/tests/jamf_parser_robustness.rs | 25 +- src-tauri/tests/jamf_policy_log_parsing.rs | 11 +- src-tauri/tests/jamf_real_fixtures.rs | 9 +- .../tests/jamf_self_service_log_parsing.rs | 10 +- 113 files changed, 10616 insertions(+), 178 deletions(-) create mode 100644 crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs create mode 100644 crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs create mode 100644 crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs create mode 100644 crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs create mode 100644 crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs create mode 100644 crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/completed-without-esp-bundle/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/esp-session-facts/current/esp-sessions.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/conflicting-profile-session-identifiers/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/esp-session-facts/current/esp-sessions.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/deterministic-identity-redaction/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/identity-registration-mismatch/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/incomplete-event-channel/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/esp-session-facts/current/esp-sessions.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/invalid-timezone/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/malformed-report-section/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/evidence/esp-session-facts/current/esp-sessions.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/matching-autopilot-and-esp-session/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/network-retry-without-terminal-proof/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/network-retry-without-terminal-proof/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/network-retry-without-terminal-proof/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/network-retry-without-terminal-proof/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/no-profile-candidate/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/no-profile-candidate/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/no-profile-candidate/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-application-failure/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-application-failure/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-application-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-application-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-retrieval-failure/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-retrieval-failure/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-retrieval-failure/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/profile-retrieval-failure/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/self-deploying-source-contract-not-captured/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/self-deploying-source-contract-not-captured/evidence/self-deploying-contract/current/self-deploying-contract.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/self-deploying-source-contract-not-captured/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/self-deploying-source-contract-not-captured/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/unknown-windows-schema-version/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/unknown-windows-schema-version/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/unknown-windows-schema-version/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/unknown-windows-schema-version/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/user-driven-success-through-esp-handoff/evidence/autopilot-channel/current/autopilot-events.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/user-driven-success-through-esp-handoff/evidence/esp-session-facts/current/esp-sessions.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/user-driven-success-through-esp-handoff/evidence/mdm-diagnostics-report/current/autopilot-report.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/user-driven-success-through-esp-handoff/expected.json create mode 100644 crates/cmtraceopen-parser/tests/fixtures/intune/enrollment/windows/autopilot/user-driven-success-through-esp-handoff/manifest.json create mode 100644 crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs diff --git a/Cargo.lock b/Cargo.lock index 2a621b0db..75b1e868c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -577,7 +577,7 @@ dependencies = [ [[package]] name = "cmtrace-open" -version = "1.5.0" +version = "1.5.1" dependencies = [ "anyhow", "base64 0.23.0", diff --git a/crates/cmtraceopen-parser/src/collector/profile.rs b/crates/cmtraceopen-parser/src/collector/profile.rs index 3590a7624..c21ed2b2d 100644 --- a/crates/cmtraceopen-parser/src/collector/profile.rs +++ b/crates/cmtraceopen-parser/src/collector/profile.rs @@ -209,7 +209,10 @@ mod tests { fn filter_by_macos_jamf_yields_jamf_items_only() { let mut profile = CollectionProfile::embedded(); profile.filter_by_families(&["macos-jamf".to_string()]); - assert!(profile.total_items() >= 7, "should have at least 5 logs + 2 commands"); + assert!( + profile.total_items() >= 7, + "should have at least 5 logs + 2 commands" + ); for item in &profile.logs { assert_eq!(item.family, "macos-jamf"); } diff --git a/crates/cmtraceopen-parser/src/esp/redaction.rs b/crates/cmtraceopen-parser/src/esp/redaction.rs index 41d906287..e4260b23f 100644 --- a/crates/cmtraceopen-parser/src/esp/redaction.rs +++ b/crates/cmtraceopen-parser/src/esp/redaction.rs @@ -1369,8 +1369,7 @@ fn redact_text_for_context(value: &str, context: TextRedactionContext) -> String // the MAC matcher could pick up decimal sub-authority pairs inside it, and // IPv4 runs before IPv6 so an IPv4-mapped IPv6 address cannot leak its dotted // tail. - let redacted = - azure_storage_credential_pattern().replace_all(&redacted, "${prefix}[redacted]"); + let redacted = azure_storage_credential_pattern().replace_all(&redacted, "${prefix}[redacted]"); let redacted = ipv4_address_pattern().replace_all(&redacted, REDACTED); let redacted = mac_address_pattern().replace_all(&redacted, REDACTED); let redacted = ipv6_address_pattern().replace_all(&redacted, REDACTED); diff --git a/crates/cmtraceopen-parser/src/esp/reducer.rs b/crates/cmtraceopen-parser/src/esp/reducer.rs index e40a00b9c..a0297fd55 100644 --- a/crates/cmtraceopen-parser/src/esp/reducer.rs +++ b/crates/cmtraceopen-parser/src/esp/reducer.rs @@ -3137,7 +3137,10 @@ fn sidecar_app_state_observation( ordinal: usize, observation: &EspRegistryObservation, ) -> Option { - let field = if observation.value_name.eq_ignore_ascii_case("InstallationState") { + let field = if observation + .value_name + .eq_ignore_ascii_case("InstallationState") + { SidecarAppField::InstallationState } else if observation.value_name.eq_ignore_ascii_case("ErrorHresult") { SidecarAppField::ErrorHresult diff --git a/crates/cmtraceopen-parser/src/esp/timeline.rs b/crates/cmtraceopen-parser/src/esp/timeline.rs index 8d68f019b..41e8763fa 100644 --- a/crates/cmtraceopen-parser/src/esp/timeline.rs +++ b/crates/cmtraceopen-parser/src/esp/timeline.rs @@ -115,7 +115,10 @@ mod tests { // "...05.250Z" before "...05Z" because '.' (0x2E) < 'Z' (0x5A), which // inverts chronology; the parsed-instant key must keep 05 before 05.250. let entries = vec![ - (0usize, timeline_entry("timeline|a|b|0", "2026-07-15T12:00:05Z")), + ( + 0usize, + timeline_entry("timeline|a|b|0", "2026-07-15T12:00:05Z"), + ), ( 1usize, timeline_entry("timeline|a|b|1", "2026-07-15T12:00:05.250Z"), diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs index 97bd4b220..b300b910e 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/mod.rs @@ -1,10 +1,118 @@ //! Windows Autopilot identity, profile, and OOBE evidence outside ESP. //! -//! Owner: issue #362 of epic #356. Implementation pending. +//! Owner: issue #362 of epic #356. //! -//! ESP remains a sibling reducer in `crate::esp` and may be correlated through explicit enrollment/session keys; it is not renamed into Autopilot. +//! # Where the boundary with ESP sits //! -//! This is a reserved slot created by the parser-family skeleton so that issue -//! #362 can be implemented without editing any file shared with a sibling issue. -//! Add source classification, reduction, and findings submodules here, and -//! consume the shared contracts in [`crate::intune::evidence`]. +//! Autopilot and the Enrollment Status Page are **sibling contracts**, not one +//! contract under two names. This module owns device registration and identity, +//! profile discovery, retrieval and application, OOBE mode and deployment +//! profile facts, diagnostics-page/export evidence, and the handoff into +//! enrollment and ESP. `crate::esp` continues to own app and policy progress and +//! blocking status *after* that handoff, and nothing here re-derives it. +//! +//! The only thing crossing the boundary is an explicit key. An +//! [`AutopilotEspSessionFact`] supplied by the ESP side binds to this analysis +//! when it shares an enrollment, correlation, activity, or device identifier; +//! overlapping timestamps alone can only ever produce +//! [`AutopilotEspLinkState::TimeOnlyCandidate`] at low confidence, and are +//! refused outright when the collection's timezone is missing or unrecognizable. +//! +//! # What stays native +//! +//! EVTX decoding, registry reads, diagnostics-export generation, and live OOBE +//! interaction are all outside this crate; see the wasm32 invariant in the crate +//! root. What crosses the boundary is a set of versioned, self-declaring +//! documents plus the shared +//! [`NormalizedWindowsEvent`](crate::intune::normalized::NormalizedWindowsEvent) +//! type. Nothing here performs I/O. +//! +//! # Shape +//! +//! Deliberately the same shape as `crate::esp`, so the two read side by side: +//! +//! 1. [`sources`] declares the input contract and detects document schemas; +//! 2. [`normalize`] classifies records against Microsoft's documented event +//! table, refusing to interpret anything outside it; +//! 3. [`reduce_autopilot_bundle`] folds observations into an immutable +//! [`AutopilotSnapshot`]; +//! 4. [`derive_findings`] turns that snapshot into +//! [`IntuneFinding`](crate::intune::evidence::IntuneFinding)s, each citing +//! evidence or a coverage gap and each naming the next smallest artifact; +//! 5. [`redacted_export_projection`] makes a snapshot safe to share. +//! +//! ``` +//! use cmtraceopen_parser::intune::enrollment::windows::autopilot::{ +//! reduce_autopilot_bundle, AutopilotBundleInput, AutopilotCaptureMetadata, +//! AutopilotCaptureState, AutopilotOutcome, AutopilotSourceInput, +//! }; +//! +//! let events = r#"{ +//! "autopilotDocument": "autopilot.events", +//! "documentVersion": 1, +//! "events": [{ +//! "context": { +//! "evidenceRef": { "evidenceId": "ap:0", "sourceArtifactId": "autopilot-channel" }, +//! "provenance": { +//! "sourceKind": "eventLog", "sourceArtifactId": "autopilot-channel", +//! "filePath": null, "lineNumber": null, "recordNumber": 1, +//! "registry": null, "event": null +//! }, +//! "sourceTimestamp": null, +//! "observedAtUtc": "2026-07-31T09:00:00Z", +//! "sensitivity": "public", "parseState": "parsed", "accessState": "available" +//! }, +//! "channel": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", +//! "provider": "Microsoft-Windows-ModernDeployment-Diagnostics-Provider", +//! "eventId": 815, +//! "level": "error", +//! "task": null, "keywords": null, "recordId": 1, "activityId": null, +//! "namedData": [], +//! "message": "ZtdDeviceHasNoAssignedProfile - No profile assigned to the device." +//! }] +//! }"#; +//! +//! let snapshot = reduce_autopilot_bundle(&AutopilotBundleInput { +//! generated_at_utc: "2026-07-31T09:05:00Z".to_string(), +//! capture: AutopilotCaptureMetadata { +//! timezone: Some("UTC".to_string()), +//! ..AutopilotCaptureMetadata::default() +//! }, +//! sources: vec![AutopilotSourceInput { +//! artifact_id: "autopilot-channel".to_string(), +//! family: "autopilotEvents".to_string(), +//! capture_state: AutopilotCaptureState::Captured, +//! original_basename: Some("autopilot-events.json".to_string()), +//! sanitized_source_path: None, +//! content: Some(events.to_string()), +//! ..AutopilotSourceInput::default() +//! }], +//! events: Vec::new(), +//! }); +//! +//! assert_eq!(snapshot.outcome, AutopilotOutcome::NoProfileCandidate); +//! assert!(snapshot.findings_are_evidence_backed()); +//! ``` + +pub mod models; +pub mod normalize; +pub mod redaction; +pub mod reducer; +pub mod rules; +pub mod sources; + +pub use models::*; +pub use normalize::{ + classify_event, error_code_from_token, extract_error_code, extract_oobe_setting, + extract_policy_pair, extract_profile_state, is_autopilot_channel, +}; +pub use redaction::{redact_text, redacted_export_projection}; +pub use reducer::reduce_autopilot_bundle; +pub use rules::derive_findings; +pub use sources::{ + capture_is_validated, classify_timezone, detect_document, is_validated_schema_version, + is_validated_windows_build, AutopilotBundleInput, AutopilotCaptureState, + AutopilotDocumentDetection, AutopilotDocumentKind, AutopilotEspSessionDocument, + AutopilotEventsDocument, AutopilotReportDocument, AutopilotReportSection, AutopilotSourceInput, + AUTOPILOT_DOCUMENT_VERSION, AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER, +}; diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs new file mode 100644 index 000000000..c0758a141 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs @@ -0,0 +1,560 @@ +//! Public types for Windows Autopilot identity, profile, and OOBE evidence. +//! +//! These describe *what the evidence showed*, never what the reducer guessed. +//! Every state that implies an outcome is reachable only from an explicit, +//! citable record; everything else lands in +//! [`AutopilotOutcome::InsufficientEvidence`] alongside a named next artifact. +//! +//! The Autopilot contract is a **sibling** of `crate::esp`, not a superset of +//! it. Autopilot owns device identity/registration, profile discovery, +//! retrieval and application, OOBE facts, and the handoff into enrollment and +//! ESP. ESP continues to own app/profile progress and blocking status *after* +//! that handoff. The only thing the two share is an explicit correlation key; +//! see [`AutopilotEspLinkage`]. + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::intune::evidence::{ + intune_raw_preserving_string_enum, IntuneArtifactCoverage, IntuneErrorCode, IntuneEvidenceRef, + IntuneFinding, IntuneFindingConfidence, IntuneNamedValue, IntuneObservationContext, + IntuneParseState, +}; + +/// Schema version of the Autopilot snapshot contract. +/// +/// Bump only on a breaking change. Adding an optional field, or a variant to +/// one of the raw-preserving enums below, is additive. +pub const AUTOPILOT_SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +intune_raw_preserving_string_enum! { + /// The deployment mode a profile declared. + /// + /// Raw-preserving: Microsoft adds modes without warning, and an + /// unrecognized mode must survive export verbatim rather than becoming a + /// mode this build happens to know. + pub enum AutopilotDeploymentProfileType { + UserDriven => "userDriven", + SelfDeploying => "selfDeploying", + PreProvisioning => "preProvisioning", + HybridJoin => "hybridJoin", + ExistingDevice => "existingDevice", + } +} + +intune_raw_preserving_string_enum! { + /// An OOBE setting override token, as the Autopilot provider wrote it. + /// + /// Values such as `AUTOPILOT_OOBE_SETTINGS_AAD_JOIN_ONLY` come straight + /// from event 109 and are not a closed set. + pub enum AutopilotOobeSetting { + AadJoinOnly => "AUTOPILOT_OOBE_SETTINGS_AAD_JOIN_ONLY", + SkipKeyboard => "AUTOPILOT_OOBE_SETTINGS_SKIP_KEYBOARD", + SkipEula => "AUTOPILOT_OOBE_SETTINGS_SKIP_EULA", + } +} + +intune_raw_preserving_string_enum! { + /// A `ProfileState_*` token from event 153's state transition message. + /// + /// `ProfileUnknown` is Windows' own `ProfileState_Unknown` token, which is + /// a real observed state and quite distinct from the macro-generated + /// `Unknown(String)` catch-all for a token this build does not recognize. + pub enum AutopilotProfileStateToken { + ProfileUnknown => "ProfileState_Unknown", + Available => "ProfileState_Available", + Unavailable => "ProfileState_Unavailable", + Provisioned => "ProfileState_Provisioned", + } +} + +/// A record-level signal, classified from a validated source contract. +/// +/// Event-derived variants carry the Microsoft-documented event ID they are +/// keyed on. Every event ID outside that documented table classifies as +/// [`AutopilotSignal::Unclassified`] and is retained as raw evidence: an +/// undocumented event may not carry terminal semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotSignal { + /// Event 160: `AutopilotRetrieveSettings beginning acquisition.` + ProfileAcquisitionStarted, + /// Event 164: internet available to attempt policy download. + NetworkAvailableForDownload, + /// Event 100: `Autopilot policy [name] not found.` Documented as typically + /// transient, so this is explicitly **not** a terminal failure. + ProfilePolicyNotFound, + /// Event 161: `AutopilotManager retrieve settings succeeded.` + ProfileRetrieveSucceeded, + /// Event 153: `AutopilotManager reported the state changed from X to Y.` + ProfileStateChanged, + /// Event 111: `AutopilotRetrieveSettings succeeded.` + ProfileSettingsRetrieved, + /// Events 101, 103, 109: an OOBE setting was retrieved and processed. + OobeSettingObserved, + /// Event 163: download not required, the device is already provisioned. + DeviceAlreadyProvisioned, + /// Event 171: failed to set TPM identity confirmed. + TpmIdentityFailed, + /// Event 172: failed to set the Autopilot profile as available. + ProfileApplicationFailed, + /// Event 807: `ZtdDeviceIsNotRegistered`. + DeviceNotRegistered, + /// Event 809: the assigned profile no longer exists. + AssignedProfileMissing, + /// Event 815: no profile assigned and no tenant default. + NoAssignedProfile, + /// Event 908: serial number or product key mismatch. + IdentityMismatch, + /// A diagnostics-report or supplied-fact section, classified by its own + /// declared section kind rather than by an event ID. + ReportSection, + /// A normalized ESP session fact supplied by the sibling reducer. + EspSessionFact, + /// A record from a validated channel carrying no documented meaning. + Unclassified, +} + +impl AutopilotSignal { + /// Whether this signal by itself proves a terminal Autopilot failure. + pub fn is_terminal_failure(self) -> bool { + matches!( + self, + Self::TpmIdentityFailed + | Self::ProfileApplicationFailed + | Self::DeviceNotRegistered + | Self::AssignedProfileMissing + | Self::NoAssignedProfile + | Self::IdentityMismatch + ) + } +} + +intune_raw_preserving_string_enum! { + /// What a diagnostics-report section describes. + pub enum AutopilotSectionKind { + DeviceIdentity => "deviceIdentity", + ProfileRetrieval => "profileRetrieval", + ProfileApplication => "profileApplication", + OobeMode => "oobeMode", + EnrollmentHandoff => "enrollmentHandoff", + EspHandoff => "espHandoff", + NetworkService => "networkService", + DiagnosticsPage => "diagnosticsPage", + } +} + +intune_raw_preserving_string_enum! { + /// What a diagnostics-report section reported. + /// + /// `NotFound` and `Failed` are separate on purpose: "the service had + /// nothing for this device" and "the attempt errored" lead to different + /// next steps and different findings. + pub enum AutopilotSectionOutcome { + Succeeded => "succeeded", + Failed => "failed", + NotFound => "notFound", + Retrying => "retrying", + Mismatch => "mismatch", + Observed => "observed", + } +} + +/// Whether the collection's declared timezone can be trusted. +/// +/// The pure crate carries no timezone database, so this classifies the +/// declared value's *shape* only: a fixed UTC offset, `UTC`/`Z`, or an +/// `Area/City` identifier. Anything else is [`AutopilotTimezoneState::Invalid`], +/// which is a coverage fact, not a parse failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotTimezoneState { + Declared, + Missing, + Invalid, +} + +/// Whether observation ordering may rely on wall-clock time. +/// +/// `Unreliable` forbids every time-based inference in the reducer, including +/// time-proximity correlation with an ESP session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotTimeBasis { + Utc, + Unreliable, +} + +/// Collection-side metadata supplied with a bundle. +/// +/// None of this can be discovered by the pure crate; a native collector states +/// it, and the reducer refuses terminal semantics when the declared Windows +/// build or Autopilot schema is one this build has no validated rules for. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotCaptureMetadata { + pub collected_at_utc: Option, + pub windows_build: Option, + pub autopilot_schema_version: Option, + pub timezone: Option, +} + +/// One document the reducer was handed, and what became of it. +/// +/// This is the reducer's own judgment about the bytes, kept separate from the +/// collector's declared capture state so a collector that reports success over +/// unreadable content cannot launder it into coverage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotDocumentReport { + pub artifact_id: String, + pub declared_kind: Option, + pub declared_version: Option, + pub parse_state: IntuneParseState, + pub detail: Option, + pub observation_ids: Vec, +} + +/// A classified Autopilot record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotObservation { + pub observation_id: String, + pub context: IntuneObservationContext, + pub signal: AutopilotSignal, + pub channel: Option, + pub provider: Option, + pub event_id: Option, + pub event_version: Option, + pub activity_id: Option, + pub section_kind: Option, + pub section_outcome: Option, + pub error: Option, + pub named_data: Vec, + /// Verbatim record text. Sensitive: Autopilot messages quote tenant names, + /// serial numbers, and UPNs. + pub message: Option, +} + +impl AutopilotObservation { + /// The evidence pointer this observation is cited by. + pub fn evidence_ref(&self) -> IntuneEvidenceRef { + self.context.evidence_ref.clone() + } + + /// Look up one named-data value, case-insensitively. + pub fn named(&self, name: &str) -> Option<&str> { + self.named_data + .iter() + .find(|value| value.name.eq_ignore_ascii_case(name)) + .map(|value| value.value.as_str()) + } +} + +/// Identity and registration evidence for the device being provisioned. +/// +/// Every field is `Option` because absence is itself a diagnostic fact: +/// a bundle with no serial number cannot be used to prove a serial mismatch. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotDeviceIdentity { + pub serial_number: Option, + pub hardware_hash: Option, + pub product_key_id: Option, + pub ztd_registration_id: Option, + pub entra_device_id: Option, + pub managed_device_id: Option, + pub tenant_id: Option, + pub tenant_domain: Option, + pub device_name: Option, + pub registration_state: AutopilotRegistrationState, + pub evidence: Vec, +} + +/// Whether the device's Autopilot registration was proven. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotRegistrationState { + /// No registration evidence was present either way. + #[default] + Unknown, + Registered, + /// The service explicitly reported the device as not registered. + NotRegistered, + /// Registered identity and observed hardware identity disagree. + Mismatch, +} + +/// Profile discovery, retrieval, and application evidence. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotProfileState { + pub profile_id: Option, + pub profile_name: Option, + pub deployment_profile_type: Option, + pub candidate_state: AutopilotProfileCandidateState, + pub last_state_token: Option, + pub retrieved: bool, + pub applied: bool, + pub error: Option, + pub evidence: Vec, +} + +/// Whether a profile candidate was ever proven to exist for this device. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotProfileCandidateState { + #[default] + Unknown, + /// A profile was assigned and made available to the device. + Available, + /// The service explicitly reported no assigned profile and no default. + NoneAssigned, + /// A profile was assigned but the referenced profile no longer exists. + AssignedButMissing, + /// Acquisition started and is still pending; documented as transient. + Pending, +} + +/// OOBE facts observed for this deployment. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotOobeState { + pub settings: Vec, + pub already_provisioned: bool, + pub evidence: Vec, +} + +/// One OOBE setting override and the state it was reported in. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotOobeSettingObservation { + pub setting: AutopilotOobeSetting, + pub state: Option, + pub evidence: IntuneEvidenceRef, +} + +/// The handoff out of the local Autopilot phase. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotHandoff { + pub enrollment_observed: bool, + pub esp_observed: bool, + pub enrollment_id: Option, + pub correlation_id: Option, + pub evidence: Vec, +} + +/// How an Autopilot bundle relates to an ESP session. +/// +/// Linkage is a claim about identity, not about time. `Linked` requires an +/// explicit shared enrollment, correlation, activity, or device key. Overlapping +/// timestamps alone can only ever produce [`AutopilotEspLinkState::TimeOnlyCandidate`] +/// at low confidence, and are refused outright when the time basis is unreliable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEspLinkage { + pub state: AutopilotEspLinkState, + pub confidence: IntuneFindingConfidence, + /// The keys that actually matched, empty for every non-`Linked` state. + pub matched_keys: Vec, + pub esp_session_ids: Vec, + pub evidence: Vec, +} + +impl Default for AutopilotEspLinkage { + fn default() -> Self { + Self { + state: AutopilotEspLinkState::NotObserved, + confidence: IntuneFindingConfidence::Low, + matched_keys: Vec::new(), + esp_session_ids: Vec::new(), + evidence: Vec::new(), + } + } +} + +/// The relationship between Autopilot evidence and ESP evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotEspLinkState { + /// No ESP facts were supplied and no handoff was observed. + NotObserved, + /// A shared explicit key bound the two. + Linked, + /// ESP facts exist but share no key; only the time ranges overlap. + TimeOnlyCandidate, + /// A handoff into ESP was observed but no ESP facts were supplied. + EvidenceMissing, + /// The same key resolves to more than one ESP session. + Conflicting, +} + +/// One explicit key that bound Autopilot evidence to ESP evidence. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotCorrelationKey { + pub kind: AutopilotCorrelationKeyKind, + pub value: String, +} + +/// Which identity a correlation key expresses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotCorrelationKeyKind { + EnrollmentId, + CorrelationId, + ActivityId, + EntraDeviceId, + ManagedDeviceId, +} + +/// A supplied, already-normalized ESP session fact. +/// +/// This is a read-only projection of the sibling reducer's output. Nothing here +/// re-derives ESP state; the Autopilot reducer only checks whether an explicit +/// key binds the two. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEspSessionFact { + pub session_id: String, + pub enrollment_id: Option, + pub correlation_id: Option, + pub activity_id: Option, + pub entra_device_id: Option, + pub managed_device_id: Option, + pub started_at_utc: Option, + pub phase: Option, + pub evidence: IntuneEvidenceRef, +} + +/// Two sources that cannot both be true. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotConflict { + pub conflict_id: String, + pub kind: AutopilotConflictKind, + pub detail: String, + /// The distinct values observed, sorted, so the conflict is reproducible. + pub values: Vec, + pub evidence: Vec, +} + +/// What kind of contradiction was observed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotConflictKind { + ProfileIdentifier, + EspSessionIdentifier, + DeviceIdentity, +} + +/// The furthest local Autopilot phase with direct evidence. +/// +/// This is the furthest phase *proven*, never the furthest phase assumed. The +/// ordering is deliberate: `PartialOrd` is what lets the reducer take a maximum +/// rather than track a state machine that could regress on out-of-order input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotPhase { + /// Nothing was proven. + NoEvidence, + /// Device identity or registration evidence exists. + IdentityObserved, + /// Profile acquisition began. + ProfileDiscovery, + /// A profile was retrieved. + ProfileRetrieved, + /// A profile's OOBE settings were applied. + ProfileApplied, + /// The handoff into MDM enrollment was observed. + EnrollmentHandoff, + /// The handoff into ESP / Device Preparation was observed. + EspHandoff, +} + +/// The reduced diagnosis for the local Autopilot phase. +/// +/// Exactly one outcome is reported. The reducer's precedence puts identity and +/// profile-availability failures ahead of downstream symptoms, because a device +/// that is not registered cannot meaningfully be described as having a network +/// problem. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotOutcome { + /// The local Autopilot phase completed and handed off. + Completed, + /// Handoff into ESP was observed but no ESP evidence was supplied. + HandoffReachedEspEvidenceMissing, + /// No profile was assigned to this device. + NoProfileCandidate, + /// A profile exists but could not be retrieved. + ProfileRetrievalFailure, + /// A profile was retrieved but could not be applied. + ProfileApplicationFailure, + /// Registered identity and observed hardware identity disagree. + IdentityRegistrationMismatch, + /// The device is not registered, or registration evidence is absent. + MissingRegistrationEvidence, + /// A network or service symptom with no proven cause. + NetworkOrServiceSymptom, + /// Acquisition is pending or explicitly retrying; documented as transient. + RetryDeferred, + /// Sources contradict each other. + ContradictoryEvidence, + /// A document, Windows build, or Autopilot schema this build has no + /// validated rules for. Terminal semantics are refused. + UnknownSchema, + /// Nothing in the bundle supports any conclusion. + InsufficientEvidence, +} + +/// The immutable public result of reducing an Autopilot bundle. +/// +/// Every field is derived; nothing here is mutated after +/// [`crate::intune::enrollment::windows::autopilot::reduce_autopilot_bundle`] +/// returns. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotSnapshot { + pub schema_version: u32, + pub generated_at_utc: String, + pub capture: AutopilotCaptureMetadata, + pub timezone_state: AutopilotTimezoneState, + pub time_basis: AutopilotTimeBasis, + pub identity: AutopilotDeviceIdentity, + pub profile: AutopilotProfileState, + pub oobe: AutopilotOobeState, + pub handoff: AutopilotHandoff, + pub esp_linkage: AutopilotEspLinkage, + pub phase: AutopilotPhase, + pub outcome: AutopilotOutcome, + pub confidence: IntuneFindingConfidence, + /// The smallest artifacts that would advance this diagnosis, in the order a + /// human should collect them. + pub next_evidence_requests: Vec, + pub observations: Vec, + /// Records from a validated channel that carried no documented meaning. + /// Retained so an undocumented event stays visible instead of vanishing. + pub unclassified_observation_ids: Vec, + pub documents: Vec, + pub conflicts: Vec, + pub coverage: Vec, + pub findings: Vec, +} + +impl AutopilotSnapshot { + /// Look up one observation by id. + pub fn observation(&self, observation_id: &str) -> Option<&AutopilotObservation> { + self.observations + .iter() + .find(|observation| observation.observation_id == observation_id) + } + + /// Whether every finding cites evidence or a coverage gap. + /// + /// The reducer already refuses to emit an uncited finding; this exists so a + /// consumer can assert the invariant rather than trust it. + pub fn findings_are_evidence_backed(&self) -> bool { + self.findings.iter().all(IntuneFinding::is_evidence_backed) + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs new file mode 100644 index 000000000..7b63a1db3 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/normalize.rs @@ -0,0 +1,345 @@ +//! Classification of normalized Autopilot records into typed signals. +//! +//! # The validated event contract +//! +//! Only the event IDs Microsoft documents for the Autopilot channel carry +//! meaning here. The table below is transcribed from *Windows Autopilot +//! troubleshooting FAQ*, "What do the different Event IDs mean in the Windows +//! Autopilot event log entries in Event Viewer?". +//! +//! | ID | Documented message | +//! |-----|-----------------------------------------------------------------------| +//! | 100 | `Autopilot policy [name] not found.` (documented as usually transient) | +//! | 101 | `AutopilotGetPolicyDwordByName succeeded` | +//! | 103 | `AutopilotGetPolicyStringByName succeeded` | +//! | 109 | `AutopilotGetOobeSettingsOverride succeeded` | +//! | 111 | `AutopilotRetrieveSettings succeeded.` | +//! | 153 | `AutopilotManager reported the state changed from [x] to [y].` | +//! | 160 | `AutopilotRetrieveSettings beginning acquisition.` | +//! | 161 | `AutopilotManager retrieve settings succeeded.` | +//! | 163 | download not required; device already provisioned | +//! | 164 | internet available to attempt policy download | +//! | 171 | `AutopilotManager failed to set TPM identity confirmed. HRESULT=[x]` | +//! | 172 | `AutopilotManager failed to set Autopilot profile as available.` | +//! | 807 | `ZtdDeviceIsNotRegistered` | +//! | 809 | `ZtdDeviceHasNoAssignedProfile - Assigned profile does not exist.` | +//! | 815 | `ZtdDeviceHasNoAssignedProfile - No profile assigned […] no default.` | +//! | 908 | `SerialNumberMismatch` / `ProductKeyIdMismatch` | +//! +//! Anything outside this table classifies as +//! [`AutopilotSignal::Unclassified`]. That is the whole point: an undocumented +//! event may not be promoted into a terminal state on a guess, and an event +//! from an unrelated MDM channel may not contribute identifiers at all. + +use std::sync::OnceLock; + +use regex::Regex; + +use crate::intune::evidence::{IntuneErrorCode, IntuneNamedValue}; +use crate::intune::normalized::NormalizedWindowsEvent; + +use super::models::{AutopilotOobeSetting, AutopilotProfileStateToken, AutopilotSignal}; +use super::sources::{AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER}; + +/// Whether a record came from the validated Autopilot channel. +/// +/// Both the provider and the channel must match. A `ManagementService` record +/// from the same provider is a different contract and is not Autopilot +/// evidence, so provider alone is not enough. +pub fn is_autopilot_channel(event: &NormalizedWindowsEvent) -> bool { + event + .provider + .trim() + .eq_ignore_ascii_case(AUTOPILOT_EVENT_PROVIDER) + && event + .channel + .trim() + .eq_ignore_ascii_case(AUTOPILOT_EVENT_CHANNEL) +} + +/// Classify one normalized event against the documented table. +/// +/// Returns `None` when the record is not Autopilot evidence at all, which is +/// different from `Some(Unclassified)`: the former is silently ignored, the +/// latter is retained and surfaced as a visible gap. +pub fn classify_event(event: &NormalizedWindowsEvent) -> Option { + if !is_autopilot_channel(event) { + return None; + } + Some(match event.event_id { + 160 => AutopilotSignal::ProfileAcquisitionStarted, + 164 => AutopilotSignal::NetworkAvailableForDownload, + 100 => AutopilotSignal::ProfilePolicyNotFound, + 161 => AutopilotSignal::ProfileRetrieveSucceeded, + 153 => AutopilotSignal::ProfileStateChanged, + 111 => AutopilotSignal::ProfileSettingsRetrieved, + 101 | 103 | 109 => AutopilotSignal::OobeSettingObserved, + 163 => AutopilotSignal::DeviceAlreadyProvisioned, + 171 => AutopilotSignal::TpmIdentityFailed, + 172 => AutopilotSignal::ProfileApplicationFailed, + 807 => AutopilotSignal::DeviceNotRegistered, + 809 => AutopilotSignal::AssignedProfileMissing, + 815 => AutopilotSignal::NoAssignedProfile, + 908 => AutopilotSignal::IdentityMismatch, + _ => AutopilotSignal::Unclassified, + }) +} + +// ── Message extraction ────────────────────────────────────────────────────── + +fn hresult_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(?i)\bHRESULT\s*=\s*(?P0x[0-9a-f]{1,16}|-?\d{1,20})") + .expect("hresult regex must compile") + }) +} + +fn profile_state_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r"(?i)state changed from\s+(?PProfileState_[A-Za-z0-9_]+)\s+to\s+(?PProfileState_[A-Za-z0-9_]+)", + ) + .expect("profile state regex must compile") + }) +} + +fn oobe_setting_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + // Event 109 is documented both as `OOBE setting = NAME` and as + // `OOBE setting NAME`, so the `=` is optional. + Regex::new( + r"(?i)OOBE setting\s*=?\s*(?P[A-Z][A-Z0-9_]{3,})\s*;\s*state\s*=\s*(?P[A-Za-z0-9_]+)", + ) + .expect("oobe setting regex must compile") + }) +} + +fn policy_pair_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new( + r"(?i)policy name\s*=\s*(?P[^;]+?)\s*;\s*(?:policy )?value\s*=\s*(?P[^;]+?)\s*\.?$", + ) + .expect("policy pair regex must compile") + }) +} + +/// Extract an `HRESULT=` token from a rendered message. +/// +/// The raw token is always kept. Only an unambiguously parseable value gets a +/// decimal and a canonical hex form; a token we cannot read stays raw rather +/// than becoming a number the log never stated. +pub fn extract_error_code(message: Option<&str>) -> Option { + let captures = hresult_re().captures(message?)?; + Some(error_code_from_token(captures.name("code")?.as_str())) +} + +/// Build an [`IntuneErrorCode`] from a raw token in any of the forms Intune +/// uses: `0x80070002`, `-2147024894`, or an unsigned decimal. +pub fn error_code_from_token(raw: &str) -> IntuneErrorCode { + let trimmed = raw.trim(); + let decimal = if let Some(hex) = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16) + .ok() + .and_then(|value| u32::try_from(value).ok()) + .map(|value| i64::from(value as i32)) + .or_else(|| u64::from_str_radix(hex, 16).ok().map(|value| value as i64)) + } else { + trimmed.parse::().ok() + }; + let hex = decimal.and_then(|value| { + u32::try_from(value) + .ok() + .or_else(|| i32::try_from(value).ok().map(|value| value as u32)) + .map(|value| format!("0x{value:08X}")) + }); + IntuneErrorCode { + raw: trimmed.to_owned(), + decimal, + hex, + } +} + +/// Extract the `ProfileState_*` transition an event 153 message reports. +pub fn extract_profile_state(message: Option<&str>) -> Option { + let captures = profile_state_re().captures(message?)?; + Some(profile_state_token(captures.name("to")?.as_str())) +} + +fn profile_state_token(raw: &str) -> AutopilotProfileStateToken { + serde_json::from_value(serde_json::Value::String(raw.to_owned())) + .unwrap_or_else(|_| AutopilotProfileStateToken::Unknown(raw.to_owned())) +} + +/// Extract the OOBE setting name and state an event 109 message reports. +pub fn extract_oobe_setting(message: Option<&str>) -> Option<(AutopilotOobeSetting, String)> { + let captures = oobe_setting_re().captures(message?)?; + let setting = captures.name("setting")?.as_str(); + let state = captures.name("state")?.as_str().to_owned(); + let setting = serde_json::from_value(serde_json::Value::String(setting.to_owned())) + .unwrap_or_else(|_| AutopilotOobeSetting::Unknown(setting.to_owned())); + Some((setting, state)) +} + +/// Extract the `policy name = …; policy value = …` pair events 101 and 103 +/// report, so a caller keeps the pair without this module modeling every +/// possible setting name. +pub fn extract_policy_pair(message: Option<&str>) -> Option { + let captures = policy_pair_re().captures(message?)?; + Some(IntuneNamedValue { + name: captures.name("name")?.as_str().trim().to_owned(), + value: captures.name("value")?.as_str().trim().to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::intune::evidence::{ + IntuneAccessState, IntuneEvidenceRef, IntuneObservationContext, IntuneParseState, + IntuneProvenance, IntuneSensitivity, IntuneSourceKind, + }; + use crate::intune::normalized::NormalizedEventLevel; + + fn event(channel: &str, provider: &str, event_id: u32) -> NormalizedWindowsEvent { + NormalizedWindowsEvent { + context: IntuneObservationContext { + evidence_ref: IntuneEvidenceRef { + evidence_id: "e".to_owned(), + source_artifact_id: "a".to_owned(), + }, + provenance: IntuneProvenance { + source_kind: IntuneSourceKind::EventLog, + source_artifact_id: "a".to_owned(), + file_path: None, + line_number: None, + record_number: None, + registry: None, + event: None, + }, + source_timestamp: None, + observed_at_utc: "2026-07-31T00:00:00Z".to_owned(), + sensitivity: IntuneSensitivity::Public, + parse_state: IntuneParseState::Parsed, + access_state: IntuneAccessState::Available, + }, + channel: channel.to_owned(), + provider: provider.to_owned(), + event_id, + level: NormalizedEventLevel::Information, + task: None, + keywords: None, + record_id: None, + activity_id: None, + event_version: None, + named_data: Vec::new(), + message: None, + } + } + + #[test] + fn a_record_from_another_channel_of_the_same_provider_is_not_autopilot_evidence() { + let sibling = event( + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", + AUTOPILOT_EVENT_PROVIDER, + 161, + ); + assert_eq!(classify_event(&sibling), None); + } + + #[test] + fn documented_event_ids_map_to_their_documented_meaning() { + for (id, expected) in [ + (160, AutopilotSignal::ProfileAcquisitionStarted), + (161, AutopilotSignal::ProfileRetrieveSucceeded), + (100, AutopilotSignal::ProfilePolicyNotFound), + (807, AutopilotSignal::DeviceNotRegistered), + (815, AutopilotSignal::NoAssignedProfile), + (908, AutopilotSignal::IdentityMismatch), + ] { + let record = event(AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER, id); + assert_eq!(classify_event(&record), Some(expected), "event {id}"); + } + } + + #[test] + fn an_undocumented_event_id_stays_unclassified_rather_than_guessing() { + let record = event(AUTOPILOT_EVENT_CHANNEL, AUTOPILOT_EVENT_PROVIDER, 4242); + assert_eq!( + classify_event(&record), + Some(AutopilotSignal::Unclassified), + "an undocumented event must never acquire terminal semantics" + ); + } + + #[test] + fn a_transient_policy_not_found_is_never_a_terminal_failure() { + assert!(!AutopilotSignal::ProfilePolicyNotFound.is_terminal_failure()); + assert!(AutopilotSignal::NoAssignedProfile.is_terminal_failure()); + } + + #[test] + fn hresult_tokens_keep_their_raw_form_and_gain_both_derived_forms() { + let code = extract_error_code(Some( + "AutopilotManager failed to set TPM identity confirmed. HRESULT=0x801C03EA", + )) + .expect("an HRESULT must be extracted"); + assert_eq!(code.raw, "0x801C03EA"); + assert_eq!(code.hex.as_deref(), Some("0x801C03EA")); + assert_eq!(code.decimal, Some(-2_145_647_638)); + } + + #[test] + fn a_message_without_an_hresult_yields_no_error_code() { + assert!(extract_error_code(Some("AutopilotRetrieveSettings succeeded.")).is_none()); + assert!(extract_error_code(None).is_none()); + } + + #[test] + fn a_profile_state_transition_reports_the_destination_state() { + assert_eq!( + extract_profile_state(Some( + "AutopilotManager reported the state changed from ProfileState_Unknown to ProfileState_Available." + )), + Some(AutopilotProfileStateToken::Available) + ); + } + + #[test] + fn an_unrecognized_profile_state_round_trips_verbatim() { + let token = extract_profile_state(Some( + "state changed from ProfileState_Unknown to ProfileState_SomethingNew", + )) + .expect("a token must be extracted"); + assert_eq!( + token, + AutopilotProfileStateToken::Unknown("ProfileState_SomethingNew".to_owned()) + ); + } + + #[test] + fn an_oobe_setting_override_yields_the_setting_and_its_state() { + let (setting, state) = extract_oobe_setting(Some( + "AutopilotGetOobeSettingsOverride succeeded: OOBE setting = AUTOPILOT_OOBE_SETTINGS_AAD_JOIN_ONLY; state = enabled.", + )) + .expect("an OOBE setting must be extracted"); + assert_eq!(setting, AutopilotOobeSetting::AadJoinOnly); + assert_eq!(state, "enabled"); + } + + #[test] + fn a_policy_pair_is_kept_without_modeling_every_setting_name() { + let pair = extract_policy_pair(Some( + "AutopilotGetPolicyStringByName succeeded: policy name = CloudAssignedTenantDomain; policy value = contoso.example.", + )) + .expect("a policy pair must be extracted"); + assert_eq!(pair.name, "CloudAssignedTenantDomain"); + assert_eq!(pair.value, "contoso.example"); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs new file mode 100644 index 000000000..7b6dbe659 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/redaction.rs @@ -0,0 +1,337 @@ +//! Deterministic privacy projection for the Autopilot snapshot. +//! +//! Autopilot evidence is unusually identity-dense: serial numbers, hardware +//! hashes, tenant domains, device names, and UPNs are the *subject* of the +//! analysis rather than incidental. A projection that simply dropped them would +//! destroy the diagnosis; one that dropped nothing would make the export +//! unshareable. +//! +//! So every masked value becomes a stable token derived only from the value +//! itself. Two records that named the same device still visibly name the same +//! device, and an explicit Autopilot-to-ESP correlation key still matches its +//! ESP counterpart after masking. Masking is idempotent: a token cannot itself +//! match a rule. +//! +//! # What survives, and why +//! +//! Tenant *object* identifiers survive in the clear: the Autopilot profile id, +//! the enrollment id, the correlation id, the activity id, and the ESP session +//! id. Those are the entire reason an export is shareable -- without them the +//! reader cannot line the export up against Intune or against the sibling ESP +//! analysis, and none of them describes a person or a machine. +//! +//! *Device and user* identity does not survive: serial number, hardware hash, +//! product key, ZTD id, Entra and managed device ids, tenant id and domain, +//! device name, user principal name, and the admin-authored profile name. +//! `entraDeviceId` is both device identity and a correlation key, which is +//! exactly why masking is deterministic rather than destructive. +//! +//! Every whole-value mask is computed over the trimmed, lowercased value under +//! a single token kind, so the same identifier masks identically no matter +//! which field or which casing it arrived in. +//! +//! The hash is deliberately non-cryptographic and unsalted. It exists to make +//! equal values look equal across an export, not to resist an attacker who +//! already knows the serial number they are looking for. + +use std::sync::OnceLock; + +use regex::Regex; + +use crate::intune::evidence::{IntuneFinding, IntuneNamedValue}; + +use super::models::*; + +/// Named-data and report-value keys whose values are masked in an export. +/// +/// Deliberately excludes `profileId`, `enrollmentId`, `correlationId`, +/// `activityId`, and `espSessionId`; see the module docs. +const SENSITIVE_VALUE_KEYS: [&str; 12] = [ + "serialNumber", + "hardwareHash", + "productKeyId", + "ztdRegistrationId", + "entraDeviceId", + "managedDeviceId", + "tenantId", + "tenantDomain", + "deviceName", + "userPrincipalName", + "upn", + "profileName", +]; + +/// The single token kind used for every whole-value mask. +/// +/// One kind rather than one per field is what makes a masked `serialNumber` +/// visibly equal to the same serial masked inside a record's named data. The +/// field name already says what the value was, so a per-field kind bought +/// nothing and cost cross-field equality. +const VALUE_KIND: &str = "redacted"; + +/// FNV-1a, stable across runs, platforms, and process restarts, which +/// `DefaultHasher` explicitly is not. +fn stable_token(kind: &str, value: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("[{kind}:{hash:016x}]") +} + +/// Mask one whole value, normalizing case and surrounding space first so the +/// same identifier always produces the same token. +fn mask_value(value: &str) -> String { + if is_token(value) { + return value.to_owned(); + } + stable_token(VALUE_KIND, &value.trim().to_ascii_lowercase()) +} + +fn upn_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") + .expect("upn regex must compile") + }) +} + +/// The profile segment of a user path, in either slash direction. +/// +/// The leading `[` exclusion is what keeps the projection idempotent: an +/// already-masked `[user:…]` segment must not be masked a second time. +fn user_path_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"(?i)(?P[\\/]Users[\\/])(?P[^\\/\r\n\x22\[][^\\/\r\n\x22]*)") + .expect("user path regex must compile") + }) +} + +/// A hardware hash or similar long opaque blob embedded in free text. +/// +/// Bounded at 32 characters so a GUID (32 hex digits plus dashes, matched in +/// runs of at most 12) and an eight-digit HRESULT are both left readable; those +/// are diagnostic grammar, not identity. +fn opaque_blob_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"\b[A-Za-z0-9+/=]{40,}\b").expect("opaque blob regex must compile") + }) +} + +/// Mask the sensitive spans inside a free-text value. +pub fn redact_text(value: &str) -> String { + let masked = upn_re().replace_all(value, |captures: ®ex::Captures<'_>| { + stable_token("upn", &captures[0]) + }); + let masked = user_path_re().replace_all(&masked, |captures: ®ex::Captures<'_>| { + format!( + "{}{}", + &captures["prefix"], + stable_token("user", &captures["user"]) + ) + }); + opaque_blob_re() + .replace_all(&masked, |captures: ®ex::Captures<'_>| { + stable_token("blob", &captures[0]) + }) + .into_owned() +} + +fn redact_opt(value: &Option) -> Option { + value.as_ref().map(|value| mask_value(value)) +} + +/// Whether a value is already a mask, which is what makes the projection +/// idempotent for whole-value fields. +fn is_token(value: &str) -> bool { + // Only the exact format `[redacted:<16 lowercase hex chars>]` produced by + // stable_token() is considered already-masked. Arbitrary `[foo:bar]` + // values (e.g. event-log setting tokens) must still go through redaction. + let Some(inner) = value.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else { + return false; + }; + let Some(hex) = inner.strip_prefix("redacted:") else { + return false; + }; + hex.len() == 16 && hex.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Return a copy of `snapshot` safe to export by default. +/// +/// Idempotent: `redacted_export_projection(&redacted_export_projection(&s))` +/// serializes identically to `redacted_export_projection(&s)`. +pub fn redacted_export_projection(snapshot: &AutopilotSnapshot) -> AutopilotSnapshot { + let mut projected = snapshot.clone(); + + projected.identity = AutopilotDeviceIdentity { + serial_number: redact_opt(&snapshot.identity.serial_number), + hardware_hash: redact_opt(&snapshot.identity.hardware_hash), + product_key_id: redact_opt(&snapshot.identity.product_key_id), + ztd_registration_id: redact_opt(&snapshot.identity.ztd_registration_id), + entra_device_id: redact_opt(&snapshot.identity.entra_device_id), + managed_device_id: redact_opt(&snapshot.identity.managed_device_id), + tenant_id: redact_opt(&snapshot.identity.tenant_id), + tenant_domain: redact_opt(&snapshot.identity.tenant_domain), + device_name: redact_opt(&snapshot.identity.device_name), + registration_state: snapshot.identity.registration_state, + evidence: snapshot.identity.evidence.clone(), + }; + + // `profile_id` survives: it is a tenant object identifier and the only way + // to line an export up against the Intune profile it describes. The + // admin-authored display name does not survive. + projected.profile.profile_name = redact_opt(&snapshot.profile.profile_name); + + for observation in &mut projected.observations { + observation.message = observation.message.as_deref().map(redact_text); + observation.context.provenance.file_path = + redact_opt(&observation.context.provenance.file_path); + redact_named_values(&mut observation.named_data); + } + + for conflict in &mut projected.conflicts { + conflict.detail = redact_text(&conflict.detail); + conflict.values = conflict + .values + .iter() + .map(|value| redact_conflict_value(value)) + .collect(); + } + + for key in &mut projected.esp_linkage.matched_keys { + if !is_token(&key.value) { + key.value = stable_token(VALUE_KIND, &key.value); + } + } + + for entry in &mut projected.coverage { + entry.detail = entry.detail.as_deref().map(redact_text); + } + + projected.next_evidence_requests = projected + .next_evidence_requests + .iter() + .map(|request| redact_text(request)) + .collect(); + + for finding in &mut projected.findings { + redact_finding(finding); + } + + projected +} + +fn redact_named_values(values: &mut [IntuneNamedValue]) { + for value in values { + if SENSITIVE_VALUE_KEYS + .iter() + .any(|key| key.eq_ignore_ascii_case(&value.name)) + { + if !is_token(&value.value) { + value.value = stable_token(VALUE_KIND, &value.value); + } + } else { + value.value = redact_text(&value.value); + } + } +} + +/// Conflict values are written as `name=value` by the reducer for report +/// sections and as bare values for named-key conflicts, so both shapes are +/// handled rather than assuming one. +fn redact_conflict_value(value: &str) -> String { + match value.split_once('=') { + Some((name, raw)) + if SENSITIVE_VALUE_KEYS + .iter() + .any(|key| key.eq_ignore_ascii_case(name)) => + { + if is_token(raw) { + value.to_owned() + } else { + format!("{name}={}", stable_token(VALUE_KIND, raw)) + } + } + _ => { + if is_token(value) { + value.to_owned() + } else { + stable_token(VALUE_KIND, value) + } + } + } +} + +fn redact_finding(finding: &mut IntuneFinding) { + finding.summary = redact_text(&finding.summary); + finding.recommended_checks = finding + .recommended_checks + .iter() + .map(|check| redact_text(check)) + .collect(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn equal_values_mask_to_equal_tokens_so_correlation_survives() { + let left = stable_token(VALUE_KIND, "5CD1234ABC"); + let right = stable_token(VALUE_KIND, "5CD1234ABC"); + assert_eq!(left, right); + assert_ne!(left, stable_token(VALUE_KIND, "5CD1234ABD")); + } + + #[test] + fn masking_free_text_is_idempotent() { + let once = redact_text("signed in as synthetic.user@example.invalid"); + let twice = redact_text(&once); + assert_eq!(once, twice); + assert!(!once.contains('@')); + } + + #[test] + fn a_user_profile_segment_with_a_space_is_masked_in_full() { + let masked = redact_text(r"D:\Users\Synthetic Person\provisioning.log"); + assert!(!masked.contains("Synthetic"), "got {masked}"); + assert!(!masked.contains("Person"), "got {masked}"); + assert!(masked.contains(r"\Users\"), "the shape must survive"); + } + + #[test] + fn a_guid_and_an_hresult_survive_because_they_are_diagnostic_grammar() { + let text = "profile 11111111-2222-3333-4444-555555555555 failed HRESULT=0x801C03EA"; + assert_eq!(redact_text(text), text); + } + + #[test] + fn a_long_opaque_blob_is_masked() { + let hash = "A".repeat(64); + let masked = redact_text(&format!("hardware hash {hash}")); + assert!(!masked.contains(&hash), "got {masked}"); + } + + #[test] + fn an_already_masked_whole_value_is_left_alone() { + let token = stable_token(VALUE_KIND, "abc"); + assert!(is_token(&token)); + assert_eq!(redact_conflict_value(&token), token); + assert_eq!( + redact_conflict_value(&format!("serialNumber={token}")), + format!("serialNumber={token}") + ); + } + + #[test] + fn a_non_sensitive_conflict_key_still_masks_its_value() { + // Falling through to the bare-value branch is deliberate: a conflict + // value the reducer could not attribute to a known key is more likely + // to be identity than not. + let masked = redact_conflict_value("someOtherKey=5CD1234ABC"); + assert!(!masked.contains("5CD1234ABC"), "got {masked}"); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs new file mode 100644 index 000000000..c45345c86 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs @@ -0,0 +1,1502 @@ +//! Reduction of supplied Autopilot evidence into an immutable snapshot. +//! +//! The shape follows the ESP sibling in `crate::esp`: typed observations that +//! each carry an [`IntuneObservationContext`], then a single pass that folds +//! them into a snapshot, then `derive_findings` over that snapshot. Nothing +//! here performs I/O, and nothing mutates the snapshot after it is returned. +//! +//! Three rules constrain every inference below: +//! +//! 1. A conclusion needs an explicit record. Absence of a signal is a +//! conclusion only when the artifact that would have carried it was fully +//! captured; see [`AutopilotCaptureState::supports_negative_conclusion`]. +//! 2. Cross-sibling linkage needs an explicit shared key. Overlapping time +//! ranges can only ever produce a low-confidence candidate. +//! 3. An unknown Windows build, Autopilot schema, or document version refuses +//! terminal semantics outright rather than reasoning from a guess. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::intune::evidence::{ + IntuneAccessState, IntuneArtifactCoverage, IntuneArtifactStatus, IntuneErrorCode, + IntuneEvidenceRef, IntuneFindingConfidence, IntuneNamedValue, IntuneObservationContext, + IntuneParseState, IntuneProvenance, IntuneSensitivity, IntuneSourceKind, IntuneTimestampKind, +}; +use crate::intune::normalized::NormalizedWindowsEvent; + +use super::models::*; +use super::normalize::{ + classify_event, extract_error_code, extract_oobe_setting, extract_policy_pair, + extract_profile_state, +}; +use super::rules::derive_findings; +use super::sources::{ + capture_is_validated, classify_timezone, detect_document, AutopilotBundleInput, + AutopilotCaptureState, AutopilotDocumentDetection, AutopilotDocumentKind, + AutopilotEspSessionDocument, AutopilotEventsDocument, AutopilotReportDocument, + AutopilotReportSection, AutopilotSourceInput, AUTOPILOT_EVENT_CHANNEL, +}; + +/// Named-data and report-value keys the reducer lifts into typed identity +/// fields. Anything not listed stays in `named_data`, untouched. +const IDENTITY_KEYS: [&str; 9] = [ + "serialNumber", + "hardwareHash", + "productKeyId", + "ztdRegistrationId", + "entraDeviceId", + "managedDeviceId", + "tenantId", + "tenantDomain", + "deviceName", +]; + +/// Reduce one supplied Autopilot bundle into an immutable snapshot. +/// +/// Total: every input shape produces a snapshot. A bundle the reducer cannot +/// interpret yields [`AutopilotOutcome::InsufficientEvidence`] or +/// [`AutopilotOutcome::UnknownSchema`] plus coverage explaining why, never an +/// error and never a silent empty result. +pub fn reduce_autopilot_bundle(bundle: &AutopilotBundleInput) -> AutopilotSnapshot { + let timezone_state = classify_timezone(bundle.capture.timezone.as_deref()); + + let mut ingest = Ingest { + observed_at_utc: bundle.generated_at_utc.clone(), + ..Ingest::default() + }; + for source in &bundle.sources { + ingest.absorb_source(source); + } + for (index, event) in bundle.events.iter().enumerate() { + ingest.absorb_native_event(index, event); + } + + let mut observations = ingest.observations; + let time_basis = time_basis(timezone_state, &observations); + sort_observations(&mut observations, time_basis); + + let identity = reduce_identity(&observations, &ingest.sections); + let profile = reduce_profile(&observations, &ingest.sections); + let oobe = reduce_oobe(&observations); + let handoff = reduce_handoff(&observations, &ingest.sections); + let conflicts = detect_conflicts(&observations, &ingest.sections, &ingest.esp_sessions); + let esp_linkage = reduce_esp_linkage( + &observations, + &ingest.esp_sessions, + &handoff, + &conflicts, + time_basis, + ); + + let coverage = ingest.coverage; + // A malformed document is deliberately *not* part of this gate. Unreadable + // bytes are a coverage gap, reported as `parseFailed` coverage and its own + // finding; they say nothing about whether this build understands the + // device's Autopilot schema, which is what refusing terminal semantics is + // supposed to express. + let capture_validated = capture_is_validated(&bundle.capture) && !ingest.unsupported_documents; + + let phase = reduce_phase(&identity, &profile, &handoff); + let outcome = reduce_outcome( + capture_validated, + &conflicts, + &identity, + &profile, + &handoff, + &esp_linkage, + &observations, + &ingest.sections, + ); + let confidence = reduce_confidence(outcome, capture_validated, time_basis, &coverage); + let next_evidence_requests = next_evidence_requests(outcome, &esp_linkage, &coverage); + + let unclassified_observation_ids = observations + .iter() + .filter(|observation| observation.signal == AutopilotSignal::Unclassified) + .map(|observation| observation.observation_id.clone()) + .collect(); + + let mut snapshot = AutopilotSnapshot { + schema_version: AUTOPILOT_SNAPSHOT_SCHEMA_VERSION, + generated_at_utc: bundle.generated_at_utc.clone(), + capture: bundle.capture.clone(), + timezone_state, + time_basis, + identity, + profile, + oobe, + handoff, + esp_linkage, + phase, + outcome, + confidence, + next_evidence_requests, + observations, + unclassified_observation_ids, + documents: ingest.documents, + conflicts, + coverage, + findings: Vec::new(), + }; + snapshot.findings = derive_findings(&snapshot); + snapshot +} + +// ── Ingest ────────────────────────────────────────────────────────────────── + +#[derive(Default)] +struct Ingest { + observed_at_utc: String, + observations: Vec, + sections: Vec, + esp_sessions: Vec, + documents: Vec, + coverage: Vec, + /// Artifacts whose document declared that the channel it carries is + /// truncated. Their coverage is forced to `Capped`, because a partial + /// channel cannot support "this signal never appeared". + incomplete_artifacts: BTreeSet, + unsupported_documents: bool, +} + +impl Ingest { + fn absorb_source(&mut self, source: &AutopilotSourceInput) { + let declared_status = source.capture_state.declared_status(); + let Some(content) = source.content.as_deref() else { + self.push_coverage(source, declared_status, None); + return; + }; + + match detect_document(content) { + AutopilotDocumentDetection::Supported { kind, version } => { + match self.absorb_payload(source, &kind, content) { + Ok(observation_ids) => { + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind: Some(kind_wire(&kind)), + declared_version: Some(version), + parse_state: IntuneParseState::Parsed, + detail: None, + observation_ids, + }); + if self.incomplete_artifacts.contains(&source.artifact_id) + && declared_status == IntuneArtifactStatus::Available + { + self.push_coverage( + source, + IntuneArtifactStatus::Capped, + Some( + "The document declares the event channel incomplete, so the \ + absence of a signal in it proves nothing." + .to_owned(), + ), + ); + } else { + self.push_coverage(source, declared_status, None); + } + } + // The envelope was valid but the payload was not. That is a + // malformed document, not a supported one with no records: + // collapsing the two would let corrupt content masquerade + // as proof that nothing happened. + Err(detail) => { + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind: Some(kind_wire(&kind)), + declared_version: Some(version), + parse_state: IntuneParseState::Malformed, + detail: Some(detail.clone()), + observation_ids: Vec::new(), + }); + self.push_coverage( + source, + escalate(declared_status, IntuneParseState::Malformed), + Some(detail), + ); + } + } + } + AutopilotDocumentDetection::Unsupported { + declared_kind, + declared_version, + detail, + } => { + self.unsupported_documents = true; + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind, + declared_version, + parse_state: IntuneParseState::Unsupported, + detail: Some(detail.clone()), + observation_ids: Vec::new(), + }); + self.push_coverage( + source, + escalate(declared_status, IntuneParseState::Unsupported), + Some(detail), + ); + } + AutopilotDocumentDetection::Malformed { detail } => { + self.documents.push(AutopilotDocumentReport { + artifact_id: source.artifact_id.clone(), + declared_kind: None, + declared_version: None, + parse_state: IntuneParseState::Malformed, + detail: Some(detail.clone()), + observation_ids: Vec::new(), + }); + self.push_coverage( + source, + escalate(declared_status, IntuneParseState::Malformed), + Some(detail), + ); + } + } + } + + /// Parse a detected document's payload. + /// + /// `Err` carries why the payload could not be read, so the caller can + /// record a malformed document instead of an empty supported one. + fn absorb_payload( + &mut self, + source: &AutopilotSourceInput, + kind: &AutopilotDocumentKind, + content: &str, + ) -> Result, String> { + let mut ids = Vec::new(); + match kind { + AutopilotDocumentKind::Events => { + let document = serde_json::from_str::(content) + .map_err(|error| format!("events payload could not be read: {error}"))?; + if document.channel_complete == Some(false) { + self.incomplete_artifacts.insert(source.artifact_id.clone()); + } + for (index, event) in document.events.iter().enumerate() { + if let Some(observation) = + observation_from_event(&source.artifact_id, index, event) + { + ids.push(observation.observation_id.clone()); + self.observations.push(observation); + } + } + } + AutopilotDocumentKind::DiagnosticsReport | AutopilotDocumentKind::IdentityFacts => { + let document = serde_json::from_str::(content) + .map_err(|error| format!("report payload could not be read: {error}"))?; + for section in &document.sections { + let observation = observation_from_section(section); + ids.push(observation.observation_id.clone()); + self.observations.push(observation); + self.sections.push(section.clone()); + } + } + AutopilotDocumentKind::EspSession => { + let document = serde_json::from_str::(content) + .map_err(|error| format!("ESP session payload could not be read: {error}"))?; + for session in &document.sessions { + let observation = observation_from_esp_session(session); + ids.push(observation.observation_id.clone()); + self.observations.push(observation); + self.esp_sessions.push(session.clone()); + } + } + // Detection never yields `Supported` for an unknown kind. + AutopilotDocumentKind::Unknown(_) => {} + } + Ok(ids) + } + + fn absorb_native_event(&mut self, index: usize, event: &NormalizedWindowsEvent) { + let artifact_id = event.context.provenance.source_artifact_id.clone(); + if let Some(observation) = observation_from_event(&artifact_id, index, event) { + self.observations.push(observation); + } + } + + fn push_coverage( + &mut self, + source: &AutopilotSourceInput, + status: IntuneArtifactStatus, + detail: Option, + ) { + let evidence = self + .observations + .iter() + .filter(|observation| { + observation.context.evidence_ref.source_artifact_id == source.artifact_id + }) + .map(AutopilotObservation::evidence_ref) + .collect::>(); + self.coverage.push(IntuneArtifactCoverage { + artifact_id: source.artifact_id.clone(), + family: source.family.clone(), + status, + detail: detail.or_else(|| coverage_detail(source)), + observed_at_utc: self.observed_at_utc.clone(), + evidence, + }); + } +} + +fn coverage_detail(source: &AutopilotSourceInput) -> Option { + match source.capture_state { + AutopilotCaptureState::Capped => Some( + "The artifact was truncated, so the absence of a signal in it proves nothing." + .to_owned(), + ), + AutopilotCaptureState::Absent => source + .original_basename + .as_ref() + .map(|name| format!("{name} was not present on the device.")), + AutopilotCaptureState::AccessDenied => Some( + "The collector was not permitted to read this artifact; re-collect elevated." + .to_owned(), + ), + AutopilotCaptureState::Skipped => { + Some("The collector was told not to read this artifact.".to_owned()) + } + _ => None, + } +} + +/// Worsen a declared coverage status with what the reducer found in the bytes. +/// +/// A collector that reports success over content the reducer cannot read must +/// not be able to launder that into coverage, so parse outcomes only ever move +/// the status in the pessimistic direction. +/// +/// Statuses that are already a positive statement by the collector are left +/// alone. `Missing`, `PermissionDenied`, and `Skipped` had no bytes, so they +/// cannot also be malformed; `ParseFailed` already says exactly this; and +/// `Unsupported` means the collector recognized the source and declined it, +/// which stays true whatever its bytes turn out to contain. +fn escalate(declared: IntuneArtifactStatus, parse_state: IntuneParseState) -> IntuneArtifactStatus { + if matches!( + declared, + IntuneArtifactStatus::Missing + | IntuneArtifactStatus::PermissionDenied + | IntuneArtifactStatus::Skipped + | IntuneArtifactStatus::ParseFailed + | IntuneArtifactStatus::Unsupported + ) { + return declared; + } + match parse_state { + IntuneParseState::Malformed => IntuneArtifactStatus::ParseFailed, + IntuneParseState::Unsupported => IntuneArtifactStatus::Unsupported, + IntuneParseState::Parsed | IntuneParseState::Raw => declared, + } +} + +fn kind_wire(kind: &AutopilotDocumentKind) -> String { + serde_json::to_value(kind) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() +} + +// ── Observation construction ──────────────────────────────────────────────── + +fn observation_from_event( + artifact_id: &str, + index: usize, + event: &NormalizedWindowsEvent, +) -> Option { + let signal = classify_event(event)?; + // Prefer the evidence id the collector already assigned: it stays stable if + // the events are ever re-ordered, which a positional id would not. + let observation_id = if event.context.evidence_ref.evidence_id.trim().is_empty() { + format!("{artifact_id}:{index}") + } else { + event.context.evidence_ref.evidence_id.clone() + }; + let mut named_data = event.named_data.clone(); + if let Some(pair) = extract_policy_pair(event.message.as_deref()) { + if !named_data + .iter() + .any(|value| value.name.eq_ignore_ascii_case(&pair.name)) + { + named_data.push(pair); + } + } + Some(AutopilotObservation { + observation_id, + context: event.context.clone(), + signal, + channel: Some(event.channel.clone()), + provider: Some(event.provider.clone()), + event_id: Some(event.event_id), + event_version: event.event_version, + activity_id: event.activity_id.clone(), + section_kind: None, + section_outcome: None, + error: extract_error_code(event.message.as_deref()), + named_data, + message: event.message.clone(), + }) +} + +fn observation_from_section(section: &AutopilotReportSection) -> AutopilotObservation { + AutopilotObservation { + observation_id: section.context.evidence_ref.evidence_id.clone(), + context: section.context.clone(), + signal: AutopilotSignal::ReportSection, + channel: None, + provider: None, + event_id: None, + event_version: None, + activity_id: section + .values + .iter() + .find(|value| value.name.eq_ignore_ascii_case("activityId")) + .map(|value| value.value.clone()), + section_kind: Some(section.kind.clone()), + section_outcome: Some(section.outcome.clone()), + error: section.error.clone(), + named_data: section.values.clone(), + message: section.message.clone(), + } +} + +fn observation_from_esp_session(session: &AutopilotEspSessionFact) -> AutopilotObservation { + let mut named_data = vec![IntuneNamedValue { + name: "espSessionId".to_owned(), + value: session.session_id.clone(), + }]; + for (name, value) in [ + ("enrollmentId", session.enrollment_id.as_ref()), + ("correlationId", session.correlation_id.as_ref()), + ("activityId", session.activity_id.as_ref()), + ("entraDeviceId", session.entra_device_id.as_ref()), + ("managedDeviceId", session.managed_device_id.as_ref()), + ("espPhase", session.phase.as_ref()), + ] { + if let Some(value) = value { + named_data.push(IntuneNamedValue { + name: name.to_owned(), + value: value.clone(), + }); + } + } + AutopilotObservation { + observation_id: session.evidence.evidence_id.clone(), + context: IntuneObservationContext { + evidence_ref: session.evidence.clone(), + provenance: IntuneProvenance { + source_kind: IntuneSourceKind::SuppliedFact, + source_artifact_id: session.evidence.source_artifact_id.clone(), + file_path: None, + line_number: None, + record_number: None, + registry: None, + event: None, + }, + source_timestamp: None, + observed_at_utc: session.started_at_utc.clone().unwrap_or_default(), + sensitivity: IntuneSensitivity::Sensitive, + parse_state: IntuneParseState::Parsed, + access_state: IntuneAccessState::Available, + }, + signal: AutopilotSignal::EspSessionFact, + channel: None, + provider: None, + event_id: None, + event_version: None, + activity_id: session.activity_id.clone(), + section_kind: None, + section_outcome: None, + error: None, + named_data, + message: None, + } +} + +// ── Time ──────────────────────────────────────────────────────────────────── + +/// Decide whether wall-clock time may be used for anything. +/// +/// Both conditions must hold: the collection declared a timezone whose shape is +/// recognizable, and every timestamp that exists was normalized from an +/// explicit UTC or offset form. A bare local timestamp with no declared zone +/// would otherwise silently inherit whatever zone the reducing machine happens +/// to sit in, which is exactly the wrong-timezone inference that cannot be +/// audited afterwards. +fn time_basis( + timezone_state: AutopilotTimezoneState, + observations: &[AutopilotObservation], +) -> AutopilotTimeBasis { + if timezone_state != AutopilotTimezoneState::Declared { + return AutopilotTimeBasis::Unreliable; + } + let all_normalized = observations.iter().all(|observation| { + observation + .context + .source_timestamp + .as_ref() + .is_none_or(|timestamp| { + timestamp.normalized_utc.is_some() + && matches!( + timestamp.kind, + IntuneTimestampKind::Utc | IntuneTimestampKind::Offset + ) + }) + }); + if all_normalized { + AutopilotTimeBasis::Utc + } else { + AutopilotTimeBasis::Unreliable + } +} + +/// Order observations deterministically. +/// +/// When the time basis is unreliable the sort deliberately ignores timestamps +/// entirely and falls back to record id then observation id, so the ordering is +/// reproducible without implying a chronology the evidence does not support. +fn sort_observations(observations: &mut [AutopilotObservation], basis: AutopilotTimeBasis) { + observations.sort_by(|left, right| { + let left_time = sort_time(left, basis); + let right_time = sort_time(right, basis); + left_time + .cmp(&right_time) + .then_with(|| { + left.context + .provenance + .record_number + .cmp(&right.context.provenance.record_number) + }) + .then_with(|| left.observation_id.cmp(&right.observation_id)) + }); +} + +fn sort_time(observation: &AutopilotObservation, basis: AutopilotTimeBasis) -> Option<&str> { + if basis != AutopilotTimeBasis::Utc { + return None; + } + observation + .context + .source_timestamp + .as_ref() + .and_then(|timestamp| timestamp.normalized_utc.as_deref()) +} + +// ── Field reduction ───────────────────────────────────────────────────────── + +/// An observation is assessable only when its record was fully available and +/// parsed. Malformed or access-denied records carry no semantic signal. +fn is_assessable(observation: &AutopilotObservation) -> bool { + observation.context.access_state == IntuneAccessState::Available + && observation.context.parse_state == IntuneParseState::Parsed +} + +fn signal_observations( + observations: &[AutopilotObservation], + signal: AutopilotSignal, +) -> impl Iterator { + observations + .iter() + .filter(move |observation| is_assessable(observation) && observation.signal == signal) +} + +fn has_signal(observations: &[AutopilotObservation], signal: AutopilotSignal) -> bool { + signal_observations(observations, signal).next().is_some() +} + +fn sections_of<'a>( + sections: &'a [AutopilotReportSection], + kind: &AutopilotSectionKind, +) -> impl Iterator { + let kind = kind.clone(); + sections.iter().filter(move |section| section.kind == kind) +} + +/// Collect the distinct values a named key takes across every source. +/// +/// Returned sorted and deduplicated so that "more than one value" is a +/// reproducible fact rather than an artifact of iteration order. +fn distinct_values( + observations: &[AutopilotObservation], + key: &str, +) -> BTreeMap> { + let mut values: BTreeMap> = BTreeMap::new(); + for observation in observations.iter().filter(|obs| is_assessable(obs)) { + let Some(value) = observation.named(key) else { + continue; + }; + let value = value.trim(); + if value.is_empty() { + continue; + } + values + .entry(value.to_owned()) + .or_default() + .push(observation.evidence_ref()); + } + values +} + +fn single_value(observations: &[AutopilotObservation], key: &str) -> Option { + let values = distinct_values(observations, key); + let mut keys = values.into_keys(); + let first = keys.next()?; + // More than one distinct value is a conflict, reported separately. Picking + // one here would hide it. + if keys.next().is_some() { + return None; + } + Some(first) +} + +fn reduce_identity( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotDeviceIdentity { + let mut identity = AutopilotDeviceIdentity { + serial_number: single_value(observations, "serialNumber"), + hardware_hash: single_value(observations, "hardwareHash"), + product_key_id: single_value(observations, "productKeyId"), + ztd_registration_id: single_value(observations, "ztdRegistrationId"), + entra_device_id: single_value(observations, "entraDeviceId"), + managed_device_id: single_value(observations, "managedDeviceId"), + tenant_id: single_value(observations, "tenantId"), + tenant_domain: single_value(observations, "tenantDomain"), + device_name: single_value(observations, "deviceName"), + registration_state: AutopilotRegistrationState::Unknown, + evidence: Vec::new(), + }; + + let mut evidence = Vec::new(); + let mut state = AutopilotRegistrationState::Unknown; + + for section in sections_of(sections, &AutopilotSectionKind::DeviceIdentity) { + evidence.push(section.context.evidence_ref.clone()); + state = merge_registration_state( + state, + match section.outcome { + AutopilotSectionOutcome::Succeeded | AutopilotSectionOutcome::Observed => { + AutopilotRegistrationState::Registered + } + AutopilotSectionOutcome::NotFound => AutopilotRegistrationState::NotRegistered, + AutopilotSectionOutcome::Mismatch => AutopilotRegistrationState::Mismatch, + _ => AutopilotRegistrationState::Unknown, + }, + ); + } + for observation in signal_observations(observations, AutopilotSignal::DeviceNotRegistered) { + evidence.push(observation.evidence_ref()); + state = merge_registration_state(state, AutopilotRegistrationState::NotRegistered); + } + for observation in signal_observations(observations, AutopilotSignal::IdentityMismatch) { + evidence.push(observation.evidence_ref()); + state = merge_registration_state(state, AutopilotRegistrationState::Mismatch); + } + for observation in observations.iter().filter(|observation| { + IDENTITY_KEYS + .iter() + .any(|key| observation.named(key).is_some()) + }) { + evidence.push(observation.evidence_ref()); + } + + identity.registration_state = state; + identity.evidence = normalized_evidence(evidence); + identity +} + +/// Combine two registration observations. +/// +/// A mismatch outranks everything: a device can be both registered and +/// mismatched, and the mismatch is the fact that matters. `NotRegistered` +/// outranks `Registered` for the same reason a failure outranks a success -- +/// the negative statement is the one that blocks provisioning. +fn merge_registration_state( + current: AutopilotRegistrationState, + incoming: AutopilotRegistrationState, +) -> AutopilotRegistrationState { + fn rank(state: AutopilotRegistrationState) -> u8 { + match state { + AutopilotRegistrationState::Unknown => 0, + AutopilotRegistrationState::Registered => 1, + AutopilotRegistrationState::NotRegistered => 2, + AutopilotRegistrationState::Mismatch => 3, + } + } + if rank(incoming) > rank(current) { + incoming + } else { + current + } +} + +fn reduce_profile( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotProfileState { + let mut evidence = Vec::new(); + let mut candidate = AutopilotProfileCandidateState::Unknown; + let mut retrieved = false; + let mut applied = false; + let mut error: Option = None; + let mut last_state_token = None; + + for observation in observations { + match observation.signal { + AutopilotSignal::ProfileAcquisitionStarted + | AutopilotSignal::ProfilePolicyNotFound + | AutopilotSignal::NetworkAvailableForDownload => { + evidence.push(observation.evidence_ref()); + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Pending); + } + AutopilotSignal::ProfileRetrieveSucceeded + | AutopilotSignal::ProfileSettingsRetrieved => { + evidence.push(observation.evidence_ref()); + retrieved = true; + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + AutopilotSignal::ProfileStateChanged => { + evidence.push(observation.evidence_ref()); + let token = extract_profile_state(observation.message.as_deref()); + if matches!( + token, + Some(AutopilotProfileStateToken::Available) + | Some(AutopilotProfileStateToken::Provisioned) + ) { + applied = true; + candidate = + raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + if token.is_some() { + last_state_token = token; + } + } + AutopilotSignal::DeviceAlreadyProvisioned => { + evidence.push(observation.evidence_ref()); + applied = true; + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + AutopilotSignal::NoAssignedProfile => { + evidence.push(observation.evidence_ref()); + candidate = AutopilotProfileCandidateState::NoneAssigned; + } + AutopilotSignal::AssignedProfileMissing => { + evidence.push(observation.evidence_ref()); + candidate = AutopilotProfileCandidateState::AssignedButMissing; + } + AutopilotSignal::ProfileApplicationFailed => { + evidence.push(observation.evidence_ref()); + error = error.or_else(|| observation.error.clone()); + } + _ => {} + } + } + + for section in sections_of(sections, &AutopilotSectionKind::ProfileRetrieval) { + evidence.push(section.context.evidence_ref.clone()); + match section.outcome { + AutopilotSectionOutcome::Succeeded => { + retrieved = true; + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Available); + } + AutopilotSectionOutcome::NotFound => { + candidate = AutopilotProfileCandidateState::NoneAssigned; + } + AutopilotSectionOutcome::Retrying => { + candidate = raise_candidate(candidate, AutopilotProfileCandidateState::Pending); + } + _ => {} + } + error = error.or_else(|| section.error.clone()); + } + for section in sections_of(sections, &AutopilotSectionKind::ProfileApplication) { + evidence.push(section.context.evidence_ref.clone()); + if section.outcome == AutopilotSectionOutcome::Succeeded { + applied = true; + } + error = error.or_else(|| section.error.clone()); + } + + AutopilotProfileState { + profile_id: single_value(observations, "profileId"), + profile_name: single_value(observations, "profileName"), + deployment_profile_type: single_value(observations, "deploymentProfileType") + .map(|value| deployment_profile_type(&value)), + candidate_state: candidate, + last_state_token, + retrieved, + applied, + error, + evidence: normalized_evidence(evidence), + } +} + +/// Raise a candidate state, never lowering one that was explicitly proven. +/// +/// `NoneAssigned` and `AssignedButMissing` are assigned directly rather than +/// through this function: those are explicit service statements and must be +/// able to overwrite an earlier optimistic `Pending`. +fn raise_candidate( + current: AutopilotProfileCandidateState, + incoming: AutopilotProfileCandidateState, +) -> AutopilotProfileCandidateState { + fn rank(state: AutopilotProfileCandidateState) -> u8 { + match state { + AutopilotProfileCandidateState::Unknown => 0, + AutopilotProfileCandidateState::Pending => 1, + AutopilotProfileCandidateState::NoneAssigned => 2, + AutopilotProfileCandidateState::AssignedButMissing => 2, + AutopilotProfileCandidateState::Available => 3, + } + } + if rank(incoming) > rank(current) { + incoming + } else { + current + } +} + +fn deployment_profile_type(raw: &str) -> AutopilotDeploymentProfileType { + serde_json::from_value(serde_json::Value::String(raw.to_owned())) + .unwrap_or_else(|_| AutopilotDeploymentProfileType::Unknown(raw.to_owned())) +} + +fn reduce_oobe(observations: &[AutopilotObservation]) -> AutopilotOobeState { + let mut settings = Vec::new(); + let mut evidence = Vec::new(); + let mut already_provisioned = false; + + for observation in signal_observations(observations, AutopilotSignal::OobeSettingObserved) { + evidence.push(observation.evidence_ref()); + if let Some((setting, state)) = extract_oobe_setting(observation.message.as_deref()) { + settings.push(AutopilotOobeSettingObservation { + setting, + state: Some(state), + evidence: observation.evidence_ref(), + }); + } + } + for observation in signal_observations(observations, AutopilotSignal::DeviceAlreadyProvisioned) + { + evidence.push(observation.evidence_ref()); + already_provisioned = true; + } + + AutopilotOobeState { + settings, + already_provisioned, + evidence: normalized_evidence(evidence), + } +} + +fn reduce_handoff( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotHandoff { + let mut handoff = AutopilotHandoff::default(); + let mut evidence = Vec::new(); + + for section in sections_of(sections, &AutopilotSectionKind::EnrollmentHandoff) { + if matches!( + section.outcome, + AutopilotSectionOutcome::Succeeded | AutopilotSectionOutcome::Observed + ) { + handoff.enrollment_observed = true; + evidence.push(section.context.evidence_ref.clone()); + } + } + for section in sections_of(sections, &AutopilotSectionKind::EspHandoff) { + if matches!( + section.outcome, + AutopilotSectionOutcome::Succeeded | AutopilotSectionOutcome::Observed + ) { + handoff.esp_observed = true; + evidence.push(section.context.evidence_ref.clone()); + } + } + + handoff.enrollment_id = single_value(observations, "enrollmentId"); + handoff.correlation_id = single_value(observations, "correlationId"); + handoff.evidence = normalized_evidence(evidence); + handoff +} + +// ── Conflicts ─────────────────────────────────────────────────────────────── + +fn detect_conflicts( + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], + esp_sessions: &[AutopilotEspSessionFact], +) -> Vec { + let mut conflicts = Vec::new(); + + for (key, kind, conflict_id) in [ + ( + "profileId", + AutopilotConflictKind::ProfileIdentifier, + "conflicting-profile-id", + ), + ( + "serialNumber", + AutopilotConflictKind::DeviceIdentity, + "conflicting-serial-number", + ), + ( + "entraDeviceId", + AutopilotConflictKind::DeviceIdentity, + "conflicting-entra-device-id", + ), + ] { + let values = distinct_values(observations, key); + if values.len() < 2 { + continue; + } + let evidence = values.values().flatten().cloned().collect::>(); + conflicts.push(AutopilotConflict { + conflict_id: conflict_id.to_owned(), + kind, + detail: format!( + "{} distinct {key} values were observed across the supplied sources.", + values.len() + ), + values: values.into_keys().collect(), + evidence: normalized_evidence(evidence), + }); + } + + // One correlation key resolving to more than one ESP session means the + // bundle cannot say which session it handed off to. + let mut by_key: BTreeMap> = BTreeMap::new(); + for session in esp_sessions { + for key in session_keys(session) { + by_key + .entry(key) + .or_default() + .insert(session.session_id.clone()); + } + } + let ambiguous = by_key + .iter() + .filter(|(_, sessions)| sessions.len() > 1) + .collect::>(); + if !ambiguous.is_empty() { + let mut sessions = BTreeSet::new(); + for (_, ids) in &ambiguous { + sessions.extend(ids.iter().cloned()); + } + let evidence = esp_sessions + .iter() + .filter(|session| sessions.contains(&session.session_id)) + .map(|session| session.evidence.clone()) + .collect::>(); + conflicts.push(AutopilotConflict { + conflict_id: "conflicting-esp-session-id".to_owned(), + kind: AutopilotConflictKind::EspSessionIdentifier, + detail: "A single correlation key resolves to more than one ESP session.".to_owned(), + values: sessions.into_iter().collect(), + evidence: normalized_evidence(evidence), + }); + } + + // A report section that explicitly reports a mismatch is a conflict the + // collector already detected; carrying it here keeps the two paths uniform. + for section in sections + .iter() + .filter(|section| section.outcome == AutopilotSectionOutcome::Mismatch) + { + conflicts.push(AutopilotConflict { + conflict_id: format!("reported-mismatch-{}", section.section_id), + kind: AutopilotConflictKind::DeviceIdentity, + detail: section + .message + .clone() + .unwrap_or_else(|| "A report section reported an identity mismatch.".to_owned()), + values: section + .values + .iter() + .map(|value| format!("{}={}", value.name, value.value)) + .collect(), + evidence: vec![section.context.evidence_ref.clone()], + }); + } + + conflicts.sort_by(|left, right| left.conflict_id.cmp(&right.conflict_id)); + conflicts +} + +fn session_keys(session: &AutopilotEspSessionFact) -> Vec { + [ + ( + AutopilotCorrelationKeyKind::EnrollmentId, + session.enrollment_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::CorrelationId, + session.correlation_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::ActivityId, + session.activity_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::EntraDeviceId, + session.entra_device_id.as_ref(), + ), + ( + AutopilotCorrelationKeyKind::ManagedDeviceId, + session.managed_device_id.as_ref(), + ), + ] + .into_iter() + .filter_map(|(kind, value)| { + let value = value?.trim(); + (!value.is_empty()).then(|| AutopilotCorrelationKey { + kind, + value: value.to_ascii_lowercase(), + }) + }) + .collect() +} + +/// The explicit keys the Autopilot side of the bundle can offer. +/// +/// Deliberately skips [`AutopilotSignal::EspSessionFact`] observations: an ESP +/// fact matching its own key would be a tautology, not a correlation. +fn autopilot_keys(observations: &[AutopilotObservation]) -> BTreeSet { + const NAMED_KEYS: [(&str, AutopilotCorrelationKeyKind); 5] = [ + ("enrollmentId", AutopilotCorrelationKeyKind::EnrollmentId), + ("correlationId", AutopilotCorrelationKeyKind::CorrelationId), + ("activityId", AutopilotCorrelationKeyKind::ActivityId), + ("entraDeviceId", AutopilotCorrelationKeyKind::EntraDeviceId), + ( + "managedDeviceId", + AutopilotCorrelationKeyKind::ManagedDeviceId, + ), + ]; + + let mut keys = BTreeSet::new(); + for observation in observations + .iter() + .filter(|observation| observation.signal != AutopilotSignal::EspSessionFact) + { + for (name, kind) in NAMED_KEYS { + let Some(value) = observation.named(name).map(str::trim) else { + continue; + }; + if !value.is_empty() { + keys.insert(AutopilotCorrelationKey { + kind, + value: value.to_ascii_lowercase(), + }); + } + } + if let Some(activity_id) = observation + .activity_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + keys.insert(AutopilotCorrelationKey { + kind: AutopilotCorrelationKeyKind::ActivityId, + value: activity_id.to_ascii_lowercase(), + }); + } + } + keys +} + +fn reduce_esp_linkage( + observations: &[AutopilotObservation], + esp_sessions: &[AutopilotEspSessionFact], + handoff: &AutopilotHandoff, + conflicts: &[AutopilotConflict], + time_basis: AutopilotTimeBasis, +) -> AutopilotEspLinkage { + if esp_sessions.is_empty() { + return AutopilotEspLinkage { + state: if handoff.esp_observed { + AutopilotEspLinkState::EvidenceMissing + } else { + AutopilotEspLinkState::NotObserved + }, + confidence: if handoff.esp_observed { + IntuneFindingConfidence::High + } else { + IntuneFindingConfidence::Low + }, + matched_keys: Vec::new(), + esp_session_ids: Vec::new(), + evidence: normalized_evidence(handoff.evidence.clone()), + }; + } + + if conflicts + .iter() + .any(|conflict| conflict.kind == AutopilotConflictKind::EspSessionIdentifier) + { + return AutopilotEspLinkage { + state: AutopilotEspLinkState::Conflicting, + confidence: IntuneFindingConfidence::High, + matched_keys: Vec::new(), + esp_session_ids: sorted_unique( + esp_sessions + .iter() + .map(|session| session.session_id.clone()), + ), + evidence: normalized_evidence( + esp_sessions + .iter() + .map(|session| session.evidence.clone()) + .collect(), + ), + }; + } + + let local_keys = autopilot_keys(observations); + + let mut matched_keys = BTreeSet::new(); + let mut matched_sessions = BTreeSet::new(); + let mut evidence = Vec::new(); + for session in esp_sessions { + for key in session_keys(session) { + if local_keys.contains(&key) { + matched_keys.insert(key); + matched_sessions.insert(session.session_id.clone()); + evidence.push(session.evidence.clone()); + } + } + } + + if matched_keys.is_empty() { + // ESP facts exist but nothing binds them via an explicit key. + // Only emit TimeOnlyCandidate when the collection's time basis is UTC + // (timezone was declared and recognized) AND there is an actual + // overlap between Autopilot observation timestamps and ESP session + // start times. An unreliable time basis or a non-overlapping window + // cannot support even a low-confidence proximity claim. + let state = if time_basis == AutopilotTimeBasis::Utc + && has_time_overlap(observations, esp_sessions) + { + AutopilotEspLinkState::TimeOnlyCandidate + } else { + AutopilotEspLinkState::NotObserved + }; + return AutopilotEspLinkage { + state, + confidence: IntuneFindingConfidence::Low, + matched_keys: Vec::new(), + esp_session_ids: if state == AutopilotEspLinkState::TimeOnlyCandidate { + sorted_unique(esp_sessions.iter().map(|s| s.session_id.clone())) + } else { + Vec::new() + }, + evidence: if state == AutopilotEspLinkState::TimeOnlyCandidate { + normalized_evidence(esp_sessions.iter().map(|s| s.evidence.clone()).collect()) + } else { + Vec::new() + }, + }; + } + + for observation in observations + .iter() + .filter(|observation| observation.signal != AutopilotSignal::EspSessionFact) + { + if session_key_values(&matched_keys).any(|value| observation_mentions(observation, value)) { + evidence.push(observation.evidence_ref()); + } + } + + // A single Autopilot phase can only have produced one ESP session. + // If matched keys resolve to more than one session, the identity space + // is ambiguous; emit Conflicting rather than silently merging them. + if matched_sessions.len() > 1 { + return AutopilotEspLinkage { + state: AutopilotEspLinkState::Conflicting, + confidence: IntuneFindingConfidence::High, + matched_keys: matched_keys.into_iter().collect(), + esp_session_ids: matched_sessions.into_iter().collect(), + evidence: normalized_evidence(evidence), + }; + } + + AutopilotEspLinkage { + state: AutopilotEspLinkState::Linked, + confidence: IntuneFindingConfidence::High, + matched_keys: matched_keys.into_iter().collect(), + esp_session_ids: matched_sessions.into_iter().collect(), + evidence: normalized_evidence(evidence), + } +} + +fn session_key_values(keys: &BTreeSet) -> impl Iterator { + keys.iter().map(|key| key.value.as_str()) +} + +/// Whether Autopilot observation timestamps and ESP session timestamps overlap. +/// Uses UTC string comparison on ISO-8601 timestamps; imprecise but sufficient +/// for a low-confidence time-only candidate gate (not for a hard conclusion). +fn has_time_overlap( + observations: &[AutopilotObservation], + esp_sessions: &[AutopilotEspSessionFact], +) -> bool { + let ap_times: Vec<&str> = observations + .iter() + .filter_map(|obs| { + obs.context + .source_timestamp + .as_ref() + .and_then(|ts| ts.normalized_utc.as_deref()) + .filter(|ts| !ts.is_empty()) + }) + .collect(); + let esp_times: Vec<&str> = esp_sessions + .iter() + .filter_map(|session| { + session + .started_at_utc + .as_deref() + .filter(|ts| !ts.is_empty()) + }) + .collect(); + if ap_times.is_empty() || esp_times.is_empty() { + return false; + } + // Find the min/max of Autopilot times and check if any ESP time falls within. + let ap_min = ap_times.iter().min().copied().unwrap_or(""); + let ap_max = ap_times.iter().max().copied().unwrap_or(""); + esp_times + .iter() + .any(|esp_t| *esp_t >= ap_min && *esp_t <= ap_max) +} + +fn observation_mentions(observation: &AutopilotObservation, value: &str) -> bool { + observation + .named_data + .iter() + .any(|named| named.value.trim().eq_ignore_ascii_case(value)) + || observation + .activity_id + .as_deref() + .is_some_and(|activity| activity.trim().eq_ignore_ascii_case(value)) +} + +// ── Phase, outcome, confidence ────────────────────────────────────────────── + +fn reduce_phase( + identity: &AutopilotDeviceIdentity, + profile: &AutopilotProfileState, + handoff: &AutopilotHandoff, +) -> AutopilotPhase { + let mut phase = AutopilotPhase::NoEvidence; + if identity.registration_state != AutopilotRegistrationState::Unknown + || !identity.evidence.is_empty() + { + phase = phase.max(AutopilotPhase::IdentityObserved); + } + if profile.candidate_state != AutopilotProfileCandidateState::Unknown { + phase = phase.max(AutopilotPhase::ProfileDiscovery); + } + if profile.retrieved { + phase = phase.max(AutopilotPhase::ProfileRetrieved); + } + if profile.applied { + phase = phase.max(AutopilotPhase::ProfileApplied); + } + if handoff.enrollment_observed { + phase = phase.max(AutopilotPhase::EnrollmentHandoff); + } + if handoff.esp_observed { + phase = phase.max(AutopilotPhase::EspHandoff); + } + phase +} + +#[allow(clippy::too_many_arguments)] +fn reduce_outcome( + capture_validated: bool, + conflicts: &[AutopilotConflict], + identity: &AutopilotDeviceIdentity, + profile: &AutopilotProfileState, + handoff: &AutopilotHandoff, + esp_linkage: &AutopilotEspLinkage, + observations: &[AutopilotObservation], + sections: &[AutopilotReportSection], +) -> AutopilotOutcome { + // An unvalidated build, schema, or document version refuses every terminal + // conclusion. The evidence is still retained; only its meaning is withheld. + if !capture_validated { + return AutopilotOutcome::UnknownSchema; + } + if !conflicts.is_empty() { + return AutopilotOutcome::ContradictoryEvidence; + } + if identity.registration_state == AutopilotRegistrationState::Mismatch + || has_signal(observations, AutopilotSignal::TpmIdentityFailed) + { + return AutopilotOutcome::IdentityRegistrationMismatch; + } + if identity.registration_state == AutopilotRegistrationState::NotRegistered { + return AutopilotOutcome::MissingRegistrationEvidence; + } + if matches!( + profile.candidate_state, + AutopilotProfileCandidateState::NoneAssigned + | AutopilotProfileCandidateState::AssignedButMissing + ) { + return AutopilotOutcome::NoProfileCandidate; + } + if sections_of(sections, &AutopilotSectionKind::ProfileRetrieval) + .any(|section| section.outcome == AutopilotSectionOutcome::Failed) + { + return AutopilotOutcome::ProfileRetrievalFailure; + } + if has_signal(observations, AutopilotSignal::ProfileApplicationFailed) + || sections_of(sections, &AutopilotSectionKind::ProfileApplication) + .any(|section| section.outcome == AutopilotSectionOutcome::Failed) + { + return AutopilotOutcome::ProfileApplicationFailure; + } + if profile.applied && handoff.esp_observed { + return match esp_linkage.state { + AutopilotEspLinkState::EvidenceMissing => { + AutopilotOutcome::HandoffReachedEspEvidenceMissing + } + _ => AutopilotOutcome::Completed, + }; + } + // A network or service symptom is only reported once every proven cause + // above has been ruled out; that is what "without proven cause" means. + if sections_of(sections, &AutopilotSectionKind::NetworkService) + .any(|section| matches!(section.outcome, AutopilotSectionOutcome::Failed)) + { + return AutopilotOutcome::NetworkOrServiceSymptom; + } + if sections_of(sections, &AutopilotSectionKind::ProfileRetrieval) + .any(|section| section.outcome == AutopilotSectionOutcome::Retrying) + || (has_signal(observations, AutopilotSignal::ProfilePolicyNotFound) && !profile.retrieved) + { + return AutopilotOutcome::RetryDeferred; + } + AutopilotOutcome::InsufficientEvidence +} + +fn reduce_confidence( + outcome: AutopilotOutcome, + capture_validated: bool, + time_basis: AutopilotTimeBasis, + coverage: &[IntuneArtifactCoverage], +) -> IntuneFindingConfidence { + if matches!( + outcome, + AutopilotOutcome::UnknownSchema + | AutopilotOutcome::InsufficientEvidence + | AutopilotOutcome::NetworkOrServiceSymptom + ) { + return IntuneFindingConfidence::Low; + } + let coverage_is_complete = coverage + .iter() + .all(|entry| entry.status == IntuneArtifactStatus::Available); + if !capture_validated || time_basis != AutopilotTimeBasis::Utc || !coverage_is_complete { + return IntuneFindingConfidence::Medium; + } + IntuneFindingConfidence::High +} + +/// Name the smallest artifacts that would advance the diagnosis. +/// +/// Ordered most-specific first, so a human collects one file rather than a +/// whole bundle when one file would settle it. +fn next_evidence_requests( + outcome: AutopilotOutcome, + esp_linkage: &AutopilotEspLinkage, + coverage: &[IntuneArtifactCoverage], +) -> Vec { + let mut requests: Vec = match outcome { + AutopilotOutcome::NoProfileCandidate => vec![ + "The device's Autopilot registration and assigned deployment profile in Intune".to_owned(), + format!("{AUTOPILOT_EVENT_CHANNEL} events 809 and 815"), + ], + AutopilotOutcome::ProfileRetrievalFailure => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} events 160, 100, and 161"), + "The Autopilot section of an MdmDiagnosticsTool export (-area Autopilot)".to_owned(), + ], + AutopilotOutcome::ProfileApplicationFailure => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} events 172 and 153"), + "TpmHliInfo_Output.txt from the same diagnostics bundle".to_owned(), + ], + AutopilotOutcome::IdentityRegistrationMismatch => vec![ + "The registered serial number and hardware hash for this device in Intune".to_owned(), + format!("{AUTOPILOT_EVENT_CHANNEL} events 908 and 171"), + ], + AutopilotOutcome::MissingRegistrationEvidence => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} event 807"), + "MdmDiagReport_RegistryDump.reg from the same diagnostics bundle".to_owned(), + ], + AutopilotOutcome::NetworkOrServiceSymptom => vec![ + "A network trace or proxy log covering the Autopilot service endpoints".to_owned(), + format!("{AUTOPILOT_EVENT_CHANNEL} event 164"), + ], + AutopilotOutcome::RetryDeferred => vec![format!( + "A later capture of {AUTOPILOT_EVENT_CHANNEL} once acquisition settles" + )], + AutopilotOutcome::HandoffReachedEspEvidenceMissing => { + vec!["The ESP diagnostics bundle for the correlated enrollment".to_owned()] + } + AutopilotOutcome::ContradictoryEvidence => { + vec!["A fresh single-pass collection, so the sources share one point in time".to_owned()] + } + AutopilotOutcome::UnknownSchema => vec![ + "A diagnostics export from a Windows build this build has validated rules for" + .to_owned(), + ], + AutopilotOutcome::InsufficientEvidence => vec![ + format!("{AUTOPILOT_EVENT_CHANNEL} (microsoft-windows-moderndeployment-diagnostics-provider-autopilot.evtx)"), + "An MdmDiagnosticsTool export covering the Autopilot area".to_owned(), + ], + AutopilotOutcome::Completed => Vec::new(), + }; + + if esp_linkage.state == AutopilotEspLinkState::TimeOnlyCandidate { + requests.push( + "An enrollment, correlation, or device identifier shared by the Autopilot and ESP evidence" + .to_owned(), + ); + } + for entry in coverage + .iter() + .filter(|entry| entry.status != IntuneArtifactStatus::Available) + { + requests.push(format!( + "Re-collect {} ({})", + entry.artifact_id, + status_wire(&entry.status) + )); + } + requests.dedup(); + requests +} + +fn status_wire(status: &IntuneArtifactStatus) -> String { + serde_json::to_value(status) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .unwrap_or_default() +} + +// ── Shared helpers ────────────────────────────────────────────────────────── + +/// Sort and de-duplicate evidence so a snapshot is byte-identical across runs. +pub(super) fn normalized_evidence(mut evidence: Vec) -> Vec { + evidence.sort(); + evidence.dedup(); + evidence +} + +fn sorted_unique(values: impl IntoIterator) -> Vec { + values + .into_iter() + .collect::>() + .into_iter() + .collect() +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs new file mode 100644 index 000000000..52e06d050 --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/rules.rs @@ -0,0 +1,761 @@ +//! Findings derived from an immutable Autopilot snapshot. +//! +//! Shaped exactly like `crate::esp::rules`: one public entry point, one private +//! `push_*` per rule, and a shared constructor that **refuses to build a finding +//! citing neither evidence nor a coverage gap**. That refusal is the enforcement +//! point for the conservative-findings rule; a rule that finds nothing simply +//! returns nothing rather than asserting something unsupported. +//! +//! Every finding names the next smallest artifact that would settle it. "Collect +//! the whole MDM bundle" is not an answer when one event ID would do. + +use crate::intune::evidence::{ + IntuneArtifactStatus, IntuneEvidenceRef, IntuneFinding, IntuneFindingConfidence, + IntuneFindingSeverity, IntuneParseState, +}; + +use super::models::*; +use super::reducer::normalized_evidence; +use super::sources::AUTOPILOT_EVENT_CHANNEL; + +/// Derive stable, read-only findings from a snapshot. +/// +/// Deterministic: the rules run in a fixed order and each one sorts its own +/// evidence, so two runs over the same snapshot produce byte-identical output. +pub fn derive_findings(snapshot: &AutopilotSnapshot) -> Vec { + let mut findings = Vec::new(); + + push_unknown_schema(snapshot, &mut findings); + push_malformed_document(snapshot, &mut findings); + push_contradictory_evidence(snapshot, &mut findings); + push_identity_mismatch(snapshot, &mut findings); + push_missing_registration(snapshot, &mut findings); + push_no_profile_candidate(snapshot, &mut findings); + push_profile_retrieval_failure(snapshot, &mut findings); + push_profile_application_failure(snapshot, &mut findings); + push_network_or_service_symptom(snapshot, &mut findings); + push_retry_deferred(snapshot, &mut findings); + push_esp_evidence_missing(snapshot, &mut findings); + push_esp_time_only_candidate(snapshot, &mut findings); + push_esp_linked(snapshot, &mut findings); + push_unreliable_timestamps(snapshot, &mut findings); + push_coverage_gaps(snapshot, &mut findings); + push_unclassified_records(snapshot, &mut findings); + push_completed(snapshot, &mut findings); + + findings +} + +// ── Schema and integrity ──────────────────────────────────────────────────── + +fn push_unknown_schema(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + let unsupported = snapshot + .documents + .iter() + .filter(|document| document.parse_state == IntuneParseState::Unsupported) + .collect::>(); + let build_unvalidated = snapshot.outcome == AutopilotOutcome::UnknownSchema + && unsupported.is_empty() + && snapshot.capture.windows_build.is_some(); + if unsupported.is_empty() && !build_unvalidated { + return; + } + + let mut summary = if unsupported.is_empty() { + format!( + "Windows build {} has no validated Autopilot rules in this build, so terminal \ + semantics are withheld and the evidence is retained raw.", + snapshot + .capture + .windows_build + .as_deref() + .unwrap_or("unknown") + ) + } else { + format!( + "{} supplied document(s) declare a kind or version this build has no validated \ + rules for. Their bytes are retained as raw evidence; their meaning is not.", + unsupported.len() + ) + }; + if let Some(detail) = unsupported + .first() + .and_then(|document| document.detail.clone()) + { + summary.push(' '); + summary.push_str(&detail); + } + + push( + findings, + finding( + "autopilot-unknown-schema", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "Autopilot evidence uses a schema or build this release cannot interpret", + &summary, + &[ + "Confirm the Windows build and Autopilot diagnostics schema of the source device." + .to_owned(), + "Re-collect on a build with validated rules, or upgrade CMTrace Open.".to_owned(), + ], + snapshot + .coverage + .iter() + .filter(|entry| entry.status == IntuneArtifactStatus::Unsupported) + .flat_map(|entry| entry.evidence.iter().cloned()) + .collect(), + unsupported + .iter() + .map(|document| document.artifact_id.clone()) + .chain( + build_unvalidated + .then(|| { + snapshot + .coverage + .iter() + .map(|entry| entry.artifact_id.clone()) + }) + .into_iter() + .flatten(), + ) + .collect(), + ), + ); +} + +fn push_malformed_document(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + let malformed = snapshot + .documents + .iter() + .filter(|document| document.parse_state == IntuneParseState::Malformed) + .collect::>(); + if malformed.is_empty() { + return; + } + let detail = malformed + .iter() + .filter_map(|document| document.detail.clone()) + .next() + .unwrap_or_else(|| "The content is not a tagged Autopilot document.".to_owned()); + push( + findings, + finding( + "autopilot-report-section-malformed", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "An Autopilot report section could not be parsed", + &format!( + "{} supplied document(s) could not be interpreted, so any signal they carried is \ + absent from this analysis rather than proven absent. {detail}", + malformed.len() + ), + &[ + "Re-export the Autopilot section of the MDM diagnostics report.".to_owned(), + "Confirm the export was not truncated in transit.".to_owned(), + ], + Vec::new(), + malformed + .iter() + .map(|document| document.artifact_id.clone()) + .collect(), + ), + ); +} + +fn push_contradictory_evidence(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.conflicts.is_empty() { + return; + } + let summary = snapshot + .conflicts + .iter() + .map(|conflict| conflict.detail.clone()) + .collect::>() + .join(" "); + push( + findings, + finding( + "autopilot-contradictory-sources", + IntuneFindingSeverity::Error, + IntuneFindingConfidence::High, + "Supplied Autopilot sources contradict each other", + &format!( + "{summary} No single Autopilot outcome can be asserted while the sources disagree." + ), + &[ + "Re-collect the Autopilot evidence in one pass so every source describes the same \ + point in time." + .to_owned(), + "Confirm the bundle was not assembled from two devices or two provisioning \ + attempts." + .to_owned(), + ], + snapshot + .conflicts + .iter() + .flat_map(|conflict| conflict.evidence.iter().cloned()) + .collect(), + Vec::new(), + ), + ); +} + +// ── Identity and registration ─────────────────────────────────────────────── + +fn push_identity_mismatch(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if terminal_semantics_withheld(snapshot) { + return; + } + let tpm_failures = signal_evidence(snapshot, AutopilotSignal::TpmIdentityFailed); + if snapshot.identity.registration_state != AutopilotRegistrationState::Mismatch + && tpm_failures.is_empty() + { + return; + } + let mut evidence = snapshot.identity.evidence.clone(); + evidence.extend(tpm_failures); + push( + findings, + finding( + "autopilot-identity-registration-mismatch", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The device's hardware identity does not match its Autopilot registration", + "A cited record reports a serial number, product key, or TPM identity that disagrees \ + with the identity registered for this device. Provisioning cannot proceed until the \ + registration matches the hardware.", + &[ + "Compare the registered serial number and hardware hash in Intune with the \ + device's own values." + .to_owned(), + format!("Read {AUTOPILOT_EVENT_CHANNEL} events 908 and 171 for the exact token."), + "Deregister and re-register the device if the hardware was replaced.".to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_missing_registration(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if terminal_semantics_withheld(snapshot) + || snapshot.identity.registration_state != AutopilotRegistrationState::NotRegistered + { + return; + } + let mut evidence = signal_evidence(snapshot, AutopilotSignal::DeviceNotRegistered); + evidence.extend(snapshot.identity.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-device-not-registered", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The device is not registered for Windows Autopilot", + "A cited record reports the device as not registered (ZtdDeviceIsNotRegistered), so \ + no deployment profile can be assigned to it.", + &[ + "Confirm the device's hardware hash was imported into Intune.".to_owned(), + "MdmDiagReport_RegistryDump.reg from the same bundle shows the registration state \ + the client actually saw." + .to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +// ── Profile ───────────────────────────────────────────────────────────────── + +fn push_no_profile_candidate(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if terminal_semantics_withheld(snapshot) { + return; + } + let (title, summary) = match snapshot.profile.candidate_state { + AutopilotProfileCandidateState::NoneAssigned => ( + "No Autopilot deployment profile is assigned to this device", + "A cited record reports that no profile is assigned to the device and that the tenant \ + has no default profile, so there is nothing for Autopilot to apply.", + ), + AutopilotProfileCandidateState::AssignedButMissing => ( + "The assigned Autopilot deployment profile no longer exists", + "A cited record reports that the profile assigned to this device has been deleted \ + without being unassigned first.", + ), + _ => return, + }; + let mut evidence = signal_evidence(snapshot, AutopilotSignal::NoAssignedProfile); + evidence.extend(signal_evidence( + snapshot, + AutopilotSignal::AssignedProfileMissing, + )); + evidence.extend(snapshot.profile.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-no-profile-candidate", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + title, + summary, + &[ + "Check the device's Autopilot device record and its group/profile assignment." + .to_owned(), + format!("Read {AUTOPILOT_EVENT_CHANNEL} events 809 and 815."), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_profile_retrieval_failure(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.outcome != AutopilotOutcome::ProfileRetrievalFailure { + return; + } + let mut summary = "A cited record reports that profile retrieval failed after acquisition \ + began, so the device never received a profile to apply." + .to_owned(); + if let Some(error) = &snapshot.profile.error { + summary.push_str(&format!(" The reported code was {}.", error.raw)); + } + push( + findings, + finding( + "autopilot-profile-retrieval-failed", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The Autopilot deployment profile could not be retrieved", + &summary, + &[ + format!( + "Read {AUTOPILOT_EVENT_CHANNEL} events 160, 100, and 161 in order to see how \ + far acquisition got." + ), + "Confirm the Autopilot service endpoints are reachable from the provisioning \ + network." + .to_owned(), + ], + normalized_evidence(snapshot.profile.evidence.clone()), + Vec::new(), + ), + ); +} + +fn push_profile_application_failure( + snapshot: &AutopilotSnapshot, + findings: &mut Vec, +) { + if snapshot.outcome != AutopilotOutcome::ProfileApplicationFailure { + return; + } + let mut summary = "A cited record reports that the retrieved profile could not be made \ + available to the device, so its OOBE settings were never applied." + .to_owned(); + if let Some(error) = &snapshot.profile.error { + summary.push_str(&format!(" The reported code was {}.", error.raw)); + } + let mut evidence = signal_evidence(snapshot, AutopilotSignal::ProfileApplicationFailed); + evidence.extend(snapshot.profile.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-profile-application-failed", + IntuneFindingSeverity::Blocker, + IntuneFindingConfidence::High, + "The Autopilot deployment profile could not be applied", + &summary, + &[ + format!("Read {AUTOPILOT_EVENT_CHANNEL} events 172 and 153 for the HRESULT."), + "TpmHliInfo_Output.txt from the same bundle shows whether TPM attestation is the \ + underlying constraint." + .to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_network_or_service_symptom( + snapshot: &AutopilotSnapshot, + findings: &mut Vec, +) { + if snapshot.outcome != AutopilotOutcome::NetworkOrServiceSymptom { + return; + } + push( + findings, + finding( + "autopilot-network-symptom-without-cause", + IntuneFindingSeverity::Warning, + // Deliberately low: a connectivity symptom with every proven cause + // ruled out is a lead, not a diagnosis. + IntuneFindingConfidence::Low, + "A network or service symptom was reported with no proven cause", + "A cited record reports a network or service problem, but nothing in the bundle shows \ + which request failed or why. This is a lead to follow, not a root cause.", + &[ + "Capture a network trace or proxy log covering the Autopilot service endpoints." + .to_owned(), + format!( + "Confirm whether {AUTOPILOT_EVENT_CHANNEL} event 164 was ever logged for this \ + attempt." + ), + ], + normalized_evidence( + snapshot + .observations + .iter() + .filter(|observation| { + observation.section_kind.as_ref() + == Some(&AutopilotSectionKind::NetworkService) + }) + .map(AutopilotObservation::evidence_ref) + .collect(), + ), + Vec::new(), + ), + ); +} + +fn push_retry_deferred(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.outcome != AutopilotOutcome::RetryDeferred { + return; + } + push( + findings, + finding( + "autopilot-acquisition-deferred", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::Medium, + "Autopilot profile acquisition is still retrying", + "Acquisition began and the profile was reported as not yet found. Microsoft documents \ + this state as usually transient, so it is not treated as a failure here.", + &[format!( + "Re-collect {AUTOPILOT_EVENT_CHANNEL} once the device settles, and compare event \ + 100 against a later event 161." + )], + normalized_evidence( + signal_evidence(snapshot, AutopilotSignal::ProfilePolicyNotFound) + .into_iter() + .chain(signal_evidence( + snapshot, + AutopilotSignal::ProfileAcquisitionStarted, + )) + .collect(), + ), + Vec::new(), + ), + ); +} + +// ── ESP sibling linkage ───────────────────────────────────────────────────── + +fn push_esp_evidence_missing(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.esp_linkage.state != AutopilotEspLinkState::EvidenceMissing { + return; + } + push( + findings, + finding( + "autopilot-esp-evidence-missing", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "Autopilot handed off to ESP but no ESP evidence was supplied", + "The local Autopilot phase reached the Enrollment Status Page handoff. What happened \ + after that point is owned by the ESP reducer, and this bundle contains none of its \ + evidence, so the deployment cannot be called complete or failed.", + &[ + "Collect the ESP diagnostics bundle for the same enrollment and analyze it with \ + the ESP workspace." + .to_owned(), + "Carry the enrollment or correlation identifier across so the two analyses bind \ + explicitly." + .to_owned(), + ], + normalized_evidence(snapshot.esp_linkage.evidence.clone()), + Vec::new(), + ), + ); +} + +fn push_esp_time_only_candidate(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.esp_linkage.state != AutopilotEspLinkState::TimeOnlyCandidate { + return; + } + push( + findings, + finding( + "autopilot-esp-link-time-only", + IntuneFindingSeverity::Warning, + // Never above low: this is the rule that stops a time-only join + // from being presented as a correlation. + IntuneFindingConfidence::Low, + "ESP evidence exists but shares no identifier with the Autopilot evidence", + "The supplied ESP session facts carry no enrollment, correlation, activity, or device \ + identifier in common with the Autopilot evidence. They may describe the same \ + provisioning attempt or a different one; proximity in time is not proof.", + &[ + "Re-collect both sides in one pass so they share an enrollment or correlation \ + identifier." + .to_owned(), + "Compare the Entra device id recorded by each side.".to_owned(), + ], + normalized_evidence(snapshot.esp_linkage.evidence.clone()), + Vec::new(), + ), + ); +} + +fn push_esp_linked(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.esp_linkage.state != AutopilotEspLinkState::Linked { + return; + } + push( + findings, + finding( + "autopilot-esp-session-linked", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::High, + "Autopilot evidence is bound to an ESP session by an explicit key", + &format!( + "{} explicit correlation key(s) bind this Autopilot evidence to ESP session(s) {}. \ + ESP remains the owner of everything after the handoff.", + snapshot.esp_linkage.matched_keys.len(), + snapshot.esp_linkage.esp_session_ids.join(", ") + ), + &["Continue the investigation in the ESP workspace for the cited session.".to_owned()], + normalized_evidence(snapshot.esp_linkage.evidence.clone()), + Vec::new(), + ), + ); +} + +// ── Coverage and time ─────────────────────────────────────────────────────── + +fn push_unreliable_timestamps(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.time_basis == AutopilotTimeBasis::Utc { + return; + } + let (title, why) = match snapshot.timezone_state { + AutopilotTimezoneState::Missing => ( + "The capture declared no timezone", + "no timezone was declared for the collection", + ), + AutopilotTimezoneState::Invalid => ( + "The capture declared an unrecognizable timezone", + "the declared timezone is not a recognizable UTC offset or region identifier", + ), + AutopilotTimezoneState::Declared => ( + "Some Autopilot timestamps could not be normalized", + "at least one record carried a wall-clock time with no explicit offset", + ), + }; + push( + findings, + finding( + "autopilot-timestamps-unreliable", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + title, + &format!( + "Ordering fell back to record identity because {why}. No conclusion in this \ + analysis relies on wall-clock time, and time-proximity correlation with an ESP \ + session is refused outright." + ), + &[ + "Re-collect with the device's timezone recorded alongside the evidence.".to_owned(), + "Prefer sources that emit an explicit UTC offset per record.".to_owned(), + ], + Vec::new(), + // The gap is the capture itself, not any one artifact, so every + // artifact in the bundle is named rather than singling one out. + snapshot + .coverage + .iter() + .map(|entry| entry.artifact_id.clone()) + .collect(), + ), + ); +} + +fn push_coverage_gaps(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + let gaps = snapshot + .coverage + .iter() + .filter(|entry| { + matches!( + entry.status, + IntuneArtifactStatus::Missing + | IntuneArtifactStatus::PermissionDenied + | IntuneArtifactStatus::Capped + | IntuneArtifactStatus::Skipped + ) + }) + .collect::>(); + if gaps.is_empty() { + return; + } + let detail = gaps + .iter() + .filter_map(|entry| entry.detail.clone()) + .collect::>() + .join(" "); + push( + findings, + finding( + "autopilot-evidence-coverage-gap", + IntuneFindingSeverity::Warning, + IntuneFindingConfidence::High, + "Expected Autopilot evidence was incomplete", + &format!( + "{} expected artifact(s) were missing, truncated, skipped, or unreadable. The \ + absence of a signal in them proves nothing. {detail}", + gaps.len() + ), + &[ + "Re-collect the cited artifacts, elevated, before ruling anything out.".to_owned(), + format!( + "microsoft-windows-moderndeployment-diagnostics-provider-autopilot.evtx carries \ + {AUTOPILOT_EVENT_CHANNEL} in full." + ), + ], + gaps.iter() + .flat_map(|entry| entry.evidence.iter().cloned()) + .collect(), + gaps.iter() + .map(|entry| entry.artifact_id.clone()) + .collect(), + ), + ); +} + +fn push_unclassified_records(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.unclassified_observation_ids.is_empty() { + return; + } + let evidence = snapshot + .unclassified_observation_ids + .iter() + .filter_map(|id| snapshot.observation(id)) + .map(AutopilotObservation::evidence_ref) + .collect::>(); + push( + findings, + finding( + "autopilot-undocumented-events-retained", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::High, + "Autopilot records with no documented meaning were retained", + &format!( + "{} record(s) came from the Autopilot channel with an event ID this build has no \ + documented meaning for. They are kept as raw evidence and deliberately given no \ + terminal semantics.", + snapshot.unclassified_observation_ids.len() + ), + &[ + "Read the cited records directly; their message text may still be informative." + .to_owned(), + ], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +fn push_completed(snapshot: &AutopilotSnapshot, findings: &mut Vec) { + if snapshot.outcome != AutopilotOutcome::Completed { + return; + } + let mut evidence = snapshot.profile.evidence.clone(); + evidence.extend(snapshot.handoff.evidence.iter().cloned()); + push( + findings, + finding( + "autopilot-phase-completed", + IntuneFindingSeverity::Info, + IntuneFindingConfidence::High, + "The local Autopilot phase completed and handed off", + "A profile was retrieved and applied, and the handoff into enrollment and the \ + Enrollment Status Page was observed. Everything after this point belongs to the ESP \ + analysis, not to Autopilot.", + &["Review the correlated ESP session for what happened after the handoff.".to_owned()], + normalized_evidence(evidence), + Vec::new(), + ), + ); +} + +// ── Construction ──────────────────────────────────────────────────────────── + +/// Whether this snapshot may assert a terminal cause at all. +/// +/// When the Windows build, Autopilot schema, or a supplied document version is +/// one this release has no validated rules for, the reducer reports +/// [`AutopilotOutcome::UnknownSchema`]. Rules that key on a *state* rather than +/// on the outcome must consult this, or a blocking finding sails past the very +/// refusal the outcome exists to express. Rules that already key on +/// `snapshot.outcome` are gated by construction. +/// +/// Evidence-level rules -- coverage gaps, malformed documents, unreliable +/// timestamps, ESP linkage -- are deliberately *not* gated: those describe the +/// bundle, not a cause, and stay true whatever the schema turns out to mean. +fn terminal_semantics_withheld(snapshot: &AutopilotSnapshot) -> bool { + snapshot.outcome == AutopilotOutcome::UnknownSchema +} + +fn signal_evidence( + snapshot: &AutopilotSnapshot, + signal: AutopilotSignal, +) -> Vec { + snapshot + .observations + .iter() + .filter(|observation| observation.signal == signal) + .map(AutopilotObservation::evidence_ref) + .collect() +} + +/// Build a finding, or nothing when it would cite neither evidence nor a gap. +/// +/// This is the single enforcement point for the invariant +/// [`IntuneFinding::is_evidence_backed`] describes. Keeping it here means no +/// individual rule can forget it. +#[allow(clippy::too_many_arguments)] +fn finding( + id: &str, + severity: IntuneFindingSeverity, + confidence: IntuneFindingConfidence, + title: &str, + summary: &str, + recommended_checks: &[String], + evidence: Vec, + coverage_gap_ids: Vec, +) -> Option { + let evidence = normalized_evidence(evidence); + let mut coverage_gap_ids = coverage_gap_ids; + coverage_gap_ids.sort(); + coverage_gap_ids.dedup(); + if evidence.is_empty() && coverage_gap_ids.is_empty() { + return None; + } + Some(IntuneFinding { + finding_id: id.to_owned(), + severity, + confidence, + title: title.to_owned(), + summary: summary.to_owned(), + recommended_checks: recommended_checks.to_vec(), + evidence, + coverage_gap_ids, + }) +} + +fn push(findings: &mut Vec, finding: Option) { + if let Some(finding) = finding { + findings.push(finding); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs new file mode 100644 index 000000000..9df1167ef --- /dev/null +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs @@ -0,0 +1,528 @@ +//! The input contract: what a native collector hands the pure reducer. +//! +//! EVTX decoding, registry reads, diagnostics-export generation, and live OOBE +//! interaction are all native concerns and stay outside this crate. What +//! crosses the boundary is a set of *documents*: serializable, versioned, and +//! self-identifying. +//! +//! # Explicit schema detection +//! +//! A document is only interpreted when it says what it is. Every supported +//! document declares `autopilotDocument` (its kind) and `documentVersion`. +//! Content that omits the tag is [`IntuneParseState::Malformed`]; content that +//! declares a kind or version this build has no rules for is +//! [`IntuneParseState::Unsupported`] and is retained as raw evidence without +//! terminal semantics. Nothing is sniffed, and identifiers are never scraped +//! out of unrelated MDM report text. + +use std::sync::OnceLock; + +use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::intune::evidence::{ + intune_raw_preserving_string_enum, IntuneAccessState, IntuneArtifactStatus, IntuneErrorCode, + IntuneNamedValue, IntuneObservationContext, +}; +use crate::intune::normalized::NormalizedWindowsEvent; + +use super::models::{ + AutopilotCaptureMetadata, AutopilotEspSessionFact, AutopilotSectionKind, + AutopilotSectionOutcome, AutopilotTimezoneState, +}; + +/// The only document version this build has validated rules for. +pub const AUTOPILOT_DOCUMENT_VERSION: u32 = 1; + +/// The Autopilot diagnostics channel, per Microsoft's troubleshooting guidance. +pub const AUTOPILOT_EVENT_CHANNEL: &str = + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"; + +/// The provider that owns [`AUTOPILOT_EVENT_CHANNEL`]. +pub const AUTOPILOT_EVENT_PROVIDER: &str = + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider"; + +intune_raw_preserving_string_enum! { + /// What a supplied document contains. + pub enum AutopilotDocumentKind { + Events => "autopilot.events", + DiagnosticsReport => "autopilot.mdmDiagnosticsReport", + IdentityFacts => "autopilot.identityFacts", + EspSession => "autopilot.espSession", + } +} + +/// What the collector says happened to an artifact. +/// +/// This mirrors [`IntuneAccessState`] because only the collector can know it: +/// the pure crate cannot distinguish "the file was not there" from "we were not +/// allowed to read it" from bytes it never received. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutopilotCaptureState { + Captured, + Capped, + Absent, + AccessDenied, + Skipped, + Unsupported, + ParseFailed, +} + +impl AutopilotCaptureState { + /// The coverage status this capture state implies before the reducer has + /// looked at any bytes. + pub fn declared_status(self) -> IntuneArtifactStatus { + match self { + Self::Captured => IntuneArtifactStatus::Available, + Self::Capped => IntuneArtifactStatus::Capped, + Self::Absent => IntuneArtifactStatus::Missing, + Self::AccessDenied => IntuneArtifactStatus::PermissionDenied, + Self::Skipped => IntuneArtifactStatus::Skipped, + Self::Unsupported => IntuneArtifactStatus::Unsupported, + Self::ParseFailed => IntuneArtifactStatus::ParseFailed, + } + } + + /// The observation-level access state records from this artifact carry. + pub fn access_state(self) -> IntuneAccessState { + match self { + Self::Captured => IntuneAccessState::Available, + Self::Capped => IntuneAccessState::Capped, + Self::Absent => IntuneAccessState::Missing, + Self::AccessDenied => IntuneAccessState::PermissionDenied, + Self::Skipped => IntuneAccessState::Skipped, + Self::Unsupported => IntuneAccessState::Unsupported, + Self::ParseFailed => IntuneAccessState::Failed, + } + } + + /// Whether the absence of a signal in this artifact proves anything. + /// + /// A capped or partially collected channel cannot support a negative + /// conclusion, which is the whole reason `Capped` is not folded into + /// `Available`. + pub fn supports_negative_conclusion(self) -> bool { + self == Self::Captured + } +} + +/// One artifact the collector attempted, and its bytes when it got any. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutopilotSourceInput { + pub artifact_id: String, + /// Coarse grouping used by coverage, e.g. `autopilotEvents` or `mdmReport`. + pub family: String, + pub capture_state: AutopilotCaptureState, + pub original_basename: Option, + /// Already sanitized by the collector; never a live host path. + pub sanitized_source_path: Option, + pub content: Option, + /// Coarse artifact kind tag for rotation/fragment accounting. + pub source_kind_tag: Option, + /// Rotation index when a channel was split across multiple files. + pub rotation_index: Option, + /// Fragment index within a rotation, for further sub-splits. + pub fragment_index: Option, +} + +impl Default for AutopilotSourceInput { + fn default() -> Self { + Self { + artifact_id: String::new(), + family: String::new(), + capture_state: AutopilotCaptureState::Absent, + original_basename: None, + sanitized_source_path: None, + content: None, + source_kind_tag: None, + rotation_index: None, + fragment_index: None, + } + } +} + +/// Everything the reducer is given for one device. +#[derive(Debug, Clone, Default)] +pub struct AutopilotBundleInput { + /// The pure crate has no clock; the caller states when this ran. + pub generated_at_utc: String, + pub capture: AutopilotCaptureMetadata, + pub sources: Vec, + /// Events a native adapter already normalized, in addition to any carried + /// by an `autopilot.events` document in `sources`. + pub events: Vec, +} + +// ── Document envelope ─────────────────────────────────────────────────────── + +/// What detection concluded about one document's bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AutopilotDocumentDetection { + /// A kind and version this build has rules for. + Supported { + kind: AutopilotDocumentKind, + version: u32, + }, + /// Well-formed JSON declaring a kind or version this build cannot + /// interpret. The bytes remain valid evidence; their meaning does not. + Unsupported { + declared_kind: Option, + declared_version: Option, + detail: String, + }, + /// Not parseable as a tagged Autopilot document at all. + Malformed { detail: String }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentEnvelope { + autopilot_document: Option, + document_version: Option, +} + +/// Classify a document's bytes without interpreting its payload. +/// +/// Detection is deliberately separate from parsing so that an unknown-version +/// document produces a precise coverage fact rather than a parse error that +/// looks like corruption. +pub fn detect_document(content: &str) -> AutopilotDocumentDetection { + let envelope: DocumentEnvelope = match serde_json::from_str(content) { + Ok(envelope) => envelope, + Err(error) => { + return AutopilotDocumentDetection::Malformed { + detail: format!("content is not a JSON object: {error}"), + } + } + }; + + let Some(declared_kind) = envelope.autopilot_document else { + return AutopilotDocumentDetection::Malformed { + detail: "document does not declare autopilotDocument".to_owned(), + }; + }; + let Some(declared_version) = envelope.document_version else { + return AutopilotDocumentDetection::Unsupported { + declared_kind: Some(declared_kind), + declared_version: None, + detail: "document does not declare documentVersion".to_owned(), + }; + }; + + let kind: AutopilotDocumentKind = + match serde_json::from_value(serde_json::Value::String(declared_kind.clone())) { + Ok(kind) => kind, + // The raw-preserving enum never fails to decode a string, so this arm + // is unreachable in practice; treating it as unsupported rather than + // panicking keeps the reducer total. + Err(error) => { + return AutopilotDocumentDetection::Unsupported { + declared_kind: Some(declared_kind), + declared_version: Some(declared_version), + detail: format!("document kind could not be decoded: {error}"), + } + } + }; + + if let AutopilotDocumentKind::Unknown(raw) = &kind { + return AutopilotDocumentDetection::Unsupported { + declared_kind: Some(raw.clone()), + declared_version: Some(declared_version), + detail: format!("no validated rules for document kind {raw}"), + }; + } + if declared_version != AUTOPILOT_DOCUMENT_VERSION { + return AutopilotDocumentDetection::Unsupported { + detail: format!( + "no validated rules for {declared_kind} version {declared_version}; \ + this build understands version {AUTOPILOT_DOCUMENT_VERSION}" + ), + declared_kind: Some(declared_kind), + declared_version: Some(declared_version), + }; + } + + AutopilotDocumentDetection::Supported { + kind, + version: declared_version, + } +} + +// ── Document payloads ─────────────────────────────────────────────────────── + +/// An `autopilot.events` payload: normalized Windows event records. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEventsDocument { + #[serde(default)] + pub events: Vec, + /// True when the collector knows the channel was truncated at either end. + /// A truncated channel cannot support "this never happened". + #[serde(default)] + pub channel_complete: Option, +} + +/// One section of an MDM diagnostics report or diagnostics-page export. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotReportSection { + pub context: IntuneObservationContext, + pub section_id: String, + pub kind: AutopilotSectionKind, + pub outcome: AutopilotSectionOutcome, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub values: Vec, + #[serde(default)] + pub message: Option, +} + +/// An `autopilot.mdmDiagnosticsReport` or `autopilot.identityFacts` payload. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotReportDocument { + #[serde(default)] + pub sections: Vec, +} + +/// An `autopilot.espSession` payload: facts from the sibling ESP reducer. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutopilotEspSessionDocument { + #[serde(default)] + pub sessions: Vec, +} + +// ── Capture metadata validation ───────────────────────────────────────────── + +/// Windows builds this build has validated Autopilot rules for. +/// +/// The documented event vocabulary is stable across supported Windows 10 and +/// 11 servicing, so the gate is the *major* build line rather than an exact +/// build. A build outside this set is not an error; it means terminal +/// semantics are refused and the evidence is kept raw. +const VALIDATED_BUILD_PREFIXES: [&str; 6] = + ["10.0.19", "10.0.22", "10.0.25", "10.0.26", "19", "22"]; + +/// Whether the declared Windows build is one with validated rules. +/// +/// An undeclared build is *not* treated as unvalidated: many collectors cannot +/// read it, and refusing every conclusion on that basis would make the reducer +/// useless. An explicitly declared but unrecognized build is what triggers +/// refusal, because that is a positive statement the reducer disagrees with. +pub fn is_validated_windows_build(build: Option<&str>) -> bool { + let Some(build) = build.map(str::trim).filter(|build| !build.is_empty()) else { + return true; + }; + VALIDATED_BUILD_PREFIXES + .iter() + .any(|prefix| build.starts_with(prefix)) +} + +/// Whether the declared Autopilot schema version is one with validated rules. +pub fn is_validated_schema_version(version: Option<&str>) -> bool { + let Some(version) = version.map(str::trim).filter(|value| !value.is_empty()) else { + return true; + }; + version == "1" || version == "1.0" +} + +fn utc_offset_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"^(?i)(?:UTC|Z|GMT)?(?:[+-](?:[01]\d|2[0-3]):?[0-5]\d)?$") + .expect("utc offset regex must compile") + }) +} + +fn iana_zone_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"^[A-Za-z][A-Za-z_+-]*(?:/[A-Za-z0-9][A-Za-z0-9_+-]*){1,2}$") + .expect("iana zone regex must compile") + }) +} + +/// A Windows time zone identifier such as `Pacific Standard Time`. +/// +/// Windows collectors report this form, not an IANA name, so omitting it made +/// the single most common real input classify as invalid and needlessly +/// downgraded the time basis. Punctuation beyond spaces, hyphens, and periods +/// is excluded, which is what still rejects an annotated value like +/// `Pacific Standard Time (device local)`. +fn windows_zone_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r"^[A-Za-z][A-Za-z .\-]{2,}$").expect("windows zone regex must compile") + }) +} + +/// Classify the declared collection timezone. +/// +/// The pure crate carries no timezone database, so this checks *shape* only: a +/// fixed UTC offset, a bare `UTC`/`Z`/`GMT`, an `Area/City` identifier, or a +/// Windows time zone name. Claiming more than that would be inventing a +/// validation the crate cannot perform, so `Declared` means "this looks like a +/// timezone", never "this timezone exists". +pub fn classify_timezone(timezone: Option<&str>) -> AutopilotTimezoneState { + let Some(timezone) = timezone.map(str::trim) else { + return AutopilotTimezoneState::Missing; + }; + if timezone.is_empty() { + return AutopilotTimezoneState::Missing; + } + let looks_like_offset = utc_offset_re().is_match(timezone) + && timezone + .chars() + .any(|character| character.is_ascii_alphanumeric()); + if looks_like_offset + || iana_zone_re().is_match(timezone) + || windows_zone_re().is_match(timezone) + { + AutopilotTimezoneState::Declared + } else { + AutopilotTimezoneState::Invalid + } +} + +/// Whether the whole capture declares a context this build can reason about. +pub fn capture_is_validated(capture: &AutopilotCaptureMetadata) -> bool { + is_validated_windows_build(capture.windows_build.as_deref()) + && is_validated_schema_version(capture.autopilot_schema_version.as_deref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_tagged_current_version_document_is_supported() { + let detection = detect_document( + r#"{"autopilotDocument":"autopilot.events","documentVersion":1,"events":[]}"#, + ); + assert_eq!( + detection, + AutopilotDocumentDetection::Supported { + kind: AutopilotDocumentKind::Events, + version: 1, + } + ); + } + + #[test] + fn an_untagged_document_is_malformed_not_unsupported() { + // The distinction matters: unsupported means "we know what this is and + // refuse to interpret it", malformed means "this is not our document". + let detection = detect_document(r#"{"events":[]}"#); + assert!(matches!( + detection, + AutopilotDocumentDetection::Malformed { .. } + )); + } + + #[test] + fn a_future_version_is_unsupported_and_keeps_the_declared_values() { + let detection = detect_document( + r#"{"autopilotDocument":"autopilot.events","documentVersion":9,"events":[]}"#, + ); + match detection { + AutopilotDocumentDetection::Unsupported { + declared_kind, + declared_version, + .. + } => { + assert_eq!(declared_kind.as_deref(), Some("autopilot.events")); + assert_eq!(declared_version, Some(9)); + } + other => panic!("expected unsupported, got {other:?}"), + } + } + + #[test] + fn an_unknown_kind_is_unsupported_and_preserves_the_raw_token() { + let detection = detect_document( + r#"{"autopilotDocument":"autopilot.somethingNew","documentVersion":1}"#, + ); + match detection { + AutopilotDocumentDetection::Unsupported { declared_kind, .. } => { + assert_eq!(declared_kind.as_deref(), Some("autopilot.somethingNew")); + } + other => panic!("expected unsupported, got {other:?}"), + } + } + + #[test] + fn non_json_content_is_malformed() { + assert!(matches!( + detect_document("not a document"), + AutopilotDocumentDetection::Malformed { .. } + )); + } + + #[test] + fn timezone_shapes_are_classified_without_a_timezone_database() { + assert_eq!( + classify_timezone(Some("UTC")), + AutopilotTimezoneState::Declared + ); + assert_eq!( + classify_timezone(Some("UTC-05:00")), + AutopilotTimezoneState::Declared + ); + assert_eq!( + classify_timezone(Some("America/New_York")), + AutopilotTimezoneState::Declared + ); + assert_eq!(classify_timezone(None), AutopilotTimezoneState::Missing); + assert_eq!( + classify_timezone(Some(" ")), + AutopilotTimezoneState::Missing + ); + } + + #[test] + fn a_windows_time_zone_name_is_accepted_but_an_annotated_one_is_not() { + // Windows collectors report this form, so rejecting it downgraded the + // time basis on the most common real input there is. + assert_eq!( + classify_timezone(Some("Pacific Standard Time")), + AutopilotTimezoneState::Declared + ); + for junk in [ + "Pacific Standard Time (device local)", + "Pacific Standard Time?", + "??", + ] { + assert_eq!( + classify_timezone(Some(junk)), + AutopilotTimezoneState::Invalid, + "{junk:?} must not pass as a timezone" + ); + } + } + + #[test] + fn an_undeclared_build_is_not_treated_as_unvalidated() { + // Refusing every conclusion when the collector simply could not read + // the build would make the reducer useless on the majority of bundles. + assert!(is_validated_windows_build(None)); + assert!(is_validated_windows_build(Some("10.0.22631.4317"))); + assert!(!is_validated_windows_build(Some("10.0.99999.1"))); + } + + #[test] + fn capture_states_bind_to_coverage_and_access_states() { + assert_eq!( + AutopilotCaptureState::Capped.declared_status(), + IntuneArtifactStatus::Capped + ); + assert_eq!( + AutopilotCaptureState::Capped.access_state(), + IntuneAccessState::Capped + ); + assert!(!AutopilotCaptureState::Capped.supports_negative_conclusion()); + assert!(AutopilotCaptureState::Captured.supports_negative_conclusion()); + } +} diff --git a/crates/cmtraceopen-parser/src/intune/normalized.rs b/crates/cmtraceopen-parser/src/intune/normalized.rs index 18f6c4098..807c816a4 100644 --- a/crates/cmtraceopen-parser/src/intune/normalized.rs +++ b/crates/cmtraceopen-parser/src/intune/normalized.rs @@ -57,6 +57,7 @@ pub struct NormalizedWindowsEvent { pub keywords: Option, pub record_id: Option, pub activity_id: Option, + pub event_version: Option, pub named_data: Vec, pub message: Option, } @@ -112,8 +113,8 @@ pub struct NormalizedSettingReport { mod tests { use super::*; use crate::intune::evidence::{ - IntuneAccessState, IntuneEvidenceRef, IntuneParseState, IntuneProvenance, IntuneSensitivity, - IntuneSourceKind, + IntuneAccessState, IntuneEvidenceRef, IntuneParseState, IntuneProvenance, + IntuneSensitivity, IntuneSourceKind, }; fn context() -> IntuneObservationContext { @@ -153,6 +154,7 @@ mod tests { keywords: None, record_id: Some(7), activity_id: None, + event_version: None, named_data: vec![IntuneNamedValue { name: "NodeUri".to_owned(), value: "./Device/Vendor/MSFT/Policy".to_owned(), diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs index 43e5b7448..87ef8bc7e 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs @@ -59,12 +59,13 @@ pub fn parse_package_state_capture(json: &str) -> Result + + + PathBuf { + corpus_root(CORPUS).join(scenario) +} + +fn capture_state(raw: &str) -> AutopilotCaptureState { + serde_json::from_value(Value::String(raw.to_owned())) + .unwrap_or_else(|error| panic!("captureState {raw:?} is a known state: {error}")) +} + +fn optional_string(value: &Value) -> Option { + value.as_str().map(str::to_owned) +} + +/// Build the reducer input from a scenario's manifest and evidence on disk. +/// +/// This is the only place the test touches the filesystem: the crate itself is +/// wasm32-clean and never reads a file. +fn bundle(scenario: &str) -> AutopilotBundleInput { + let root = scenario_root(scenario); + let manifest = load_json(&root.join("manifest.json")); + + let capture = &manifest["capture"]; + let sources = manifest["artifacts"] + .as_array() + .expect("manifest artifacts must be an array") + .iter() + .map(|artifact| AutopilotSourceInput { + artifact_id: artifact["artifactId"] + .as_str() + .expect("artifactId") + .to_owned(), + family: artifact["family"].as_str().unwrap_or_default().to_owned(), + capture_state: capture_state(artifact["captureState"].as_str().expect("captureState")), + original_basename: optional_string(&artifact["originalBasename"]), + sanitized_source_path: optional_string(&artifact["sanitizedSourcePath"]), + content: artifact["relativePath"].as_str().map(|relative| { + std::fs::read_to_string(root.join(relative)) + .unwrap_or_else(|error| panic!("{relative} is readable: {error}")) + }), + ..AutopilotSourceInput::default() + }) + .collect(); + + AutopilotBundleInput { + generated_at_utc: manifest["generatedAtUtc"] + .as_str() + .expect("generatedAtUtc") + .to_owned(), + capture: AutopilotCaptureMetadata { + collected_at_utc: optional_string(&capture["collectedAtUtc"]), + windows_build: optional_string(&capture["windowsBuild"]), + autopilot_schema_version: optional_string(&capture["autopilotSchemaVersion"]), + timezone: optional_string(&capture["timezone"]), + }, + sources, + events: Vec::new(), + } +} + +fn reduce(scenario: &str) -> (AutopilotSnapshot, Value) { + let expected = load_json(&scenario_root(scenario).join("expected.json")); + (reduce_autopilot_bundle(&bundle(scenario)), expected) +} + +fn wire(value: &impl serde::Serialize) -> Value { + serde_json::to_value(value).expect("snapshot must serialize") +} + +// ── The shared contract ───────────────────────────────────────────────────── + +#[test] +fn the_corpus_contains_exactly_the_required_fixture_matrix() { + assert_eq!( + scenario_names(&corpus_root(CORPUS)), + SCENARIOS.map(str::to_owned).to_vec(), + "issue #362 pins this matrix; adding or dropping a scenario is a contract change" + ); +} + +#[test] +fn every_scenario_satisfies_the_shared_fixture_contract() { + let mut failures = Failures::new(); + for scenario in SCENARIOS { + let root = scenario_root(scenario); + failures.absorb(validate_scenario( + scenario, + &root, + &load_json(&root.join("manifest.json")), + &load_json(&root.join("expected.json")), + )); + } + failures.assert_empty("autopilot corpus"); +} + +/// The harness must actually reject a corrupted copy of this corpus, not merely +/// pass over the clean one. +#[test] +fn a_corrupted_byte_count_in_this_corpus_is_rejected() { + let scenario = "no-profile-candidate"; + let root = scenario_root(scenario); + let manifest = load_json(&root.join("manifest.json")); + let expected = load_json(&root.join("expected.json")); + + let failures = validate_scenario( + scenario, + &root, + &mutated(&manifest, "/artifacts/0/bytesCopied", json!(1)), + &expected, + ); + assert!( + failures + .entries() + .iter() + .any(|entry| entry.contains("bytesCopied")), + "expected a bytesCopied failure, got {:?}", + failures.entries() + ); +} + +// ── Autopilot semantics ───────────────────────────────────────────────────── + +/// Assert one scenario's reduction against its stated contract. +fn assert_scenario(scenario: &str) -> AutopilotSnapshot { + let (snapshot, expected) = reduce(scenario); + let value = wire(&snapshot); + let at = scenario; + + for (pointer, key) in [ + ("/outcome", "outcome"), + ("/phase", "phase"), + ("/confidence", "confidence"), + ("/timezoneState", "timezoneState"), + ("/timeBasis", "timeBasis"), + ] { + assert_eq!( + value.pointer(pointer).unwrap_or(&Value::Null), + &expected[key], + "{at}: {key}" + ); + } + assert_eq!( + value["identity"]["registrationState"], expected["registrationState"], + "{at}: registrationState" + ); + assert_eq!( + value["profile"]["candidateState"], expected["profileCandidateState"], + "{at}: profileCandidateState" + ); + assert_eq!( + value["profile"]["retrieved"], expected["profileRetrieved"], + "{at}: profileRetrieved" + ); + assert_eq!( + value["profile"]["applied"], expected["profileApplied"], + "{at}: profileApplied" + ); + + // ESP stays a sibling: the snapshot records how the two bind and nothing + // about what ESP itself concluded. + assert_eq!( + value["espLinkage"]["state"], expected["espLinkState"], + "{at}: espLinkState" + ); + assert_eq!( + value["espLinkage"]["confidence"], expected["espLinkConfidence"], + "{at}: espLinkConfidence" + ); + assert_eq!( + value["espLinkage"]["espSessionIds"], expected["espSessionIds"], + "{at}: espSessionIds" + ); + assert_eq!( + snapshot + .esp_linkage + .matched_keys + .iter() + .map(|key| wire(&key.kind)) + .collect::(), + expected["matchedKeyKinds"], + "{at}: matched correlation key kinds" + ); + + assert_eq!( + snapshot + .conflicts + .iter() + .map(|conflict| Value::String(conflict.conflict_id.clone())) + .collect::(), + expected["conflictIds"], + "{at}: conflictIds" + ); + + for document in &snapshot.documents { + let want = &expected["documentParseStates"][&document.artifact_id]; + assert!( + !want.is_null(), + "{at}: document {} has no expected parse state", + document.artifact_id + ); + assert_eq!( + wire(&document.parse_state), + *want, + "{at}: parse state for {}", + document.artifact_id + ); + } + assert_eq!( + snapshot.documents.len(), + expected["documentParseStates"] + .as_object() + .expect("documentParseStates") + .len(), + "{at}: every expected document must be reported" + ); + + assert_eq!( + snapshot.unclassified_observation_ids.len() as u64, + expected["unclassifiedObservationCount"] + .as_u64() + .expect("unclassifiedObservationCount"), + "{at}: unclassified observation count" + ); + + assert_coverage_matches_manifest(at, &snapshot, &expected); + assert_findings(at, &snapshot, &expected); + + // The invariant every leaf of epic #356 owes: no uncited conclusions. + assert!( + snapshot.findings_are_evidence_backed(), + "{at}: a finding cited neither evidence nor a coverage gap" + ); + + snapshot +} + +fn assert_coverage_matches_manifest(at: &str, snapshot: &AutopilotSnapshot, expected: &Value) { + let actual = snapshot + .coverage + .iter() + .map(|entry| json!({ "artifactId": entry.artifact_id, "status": wire(&entry.status) })) + .collect::(); + assert_eq!(actual, expected["coverage"], "{at}: coverage"); +} + +fn assert_findings(at: &str, snapshot: &AutopilotSnapshot, expected: &Value) { + let actual_ids = snapshot + .findings + .iter() + .map(|finding| Value::String(finding.finding_id.clone())) + .collect::(); + assert_eq!(actual_ids, expected["findingIds"], "{at}: findingIds"); + + let golden = expected["findings"] + .as_array() + .unwrap_or_else(|| panic!("{at}: expected.json must carry a findings array")); + assert_eq!( + golden.len(), + snapshot.findings.len(), + "{at}: findings golden is stale; regenerate with UPDATE_AUTOPILOT_FINDINGS=1" + ); + for (actual, want) in snapshot.findings.iter().zip(golden) { + assert_eq!( + wire(actual), + *want, + "{at}: finding {} drifted from its golden", + actual.finding_id + ); + } +} + +// ── The required fixture matrix ───────────────────────────────────────────── + +#[test] +fn user_driven_success_reaches_the_esp_handoff_and_links_by_an_explicit_key() { + let snapshot = assert_scenario("user-driven-success-through-esp-handoff"); + assert_eq!( + snapshot.oobe.settings.len(), + 1, + "the OOBE settings override must be retained as a typed fact" + ); + assert_eq!( + snapshot.profile.profile_id.as_deref(), + Some("11111111-2222-3333-4444-555555555555") + ); +} + +/// Issue #362 explicitly defers self-deploying and pre-provisioning "only after +/// its actual source contract is captured". Until then the honest reduction is +/// a refusal, not a guess -- so this fixture asserts the refusal. +#[test] +fn a_self_deploying_sample_without_a_captured_source_contract_asserts_nothing_terminal() { + let snapshot = assert_scenario("self-deploying-source-contract-not-captured"); + assert!( + snapshot + .documents + .iter() + .any(|document| document.declared_kind.as_deref() + == Some("autopilot.selfDeployingContract")), + "the unvalidated document's declared kind must survive verbatim" + ); +} + +#[test] +fn no_profile_candidate_outranks_the_transient_lookup_that_preceded_it() { + assert_scenario("no-profile-candidate"); +} + +#[test] +fn profile_retrieval_failure_is_distinct_from_having_no_candidate() { + let snapshot = assert_scenario("profile-retrieval-failure"); + assert_eq!( + snapshot + .profile + .error + .as_ref() + .map(|error| error.raw.as_str()), + Some("0x80072EE2"), + "the reported code must survive in the form the source wrote it" + ); +} + +#[test] +fn profile_application_failure_keeps_retrieval_marked_as_succeeded() { + assert_scenario("profile-application-failure"); +} + +#[test] +fn an_identity_mismatch_outranks_every_downstream_profile_symptom() { + assert_scenario("identity-registration-mismatch"); +} + +#[test] +fn a_network_symptom_without_a_proven_cause_stays_low_confidence() { + let snapshot = assert_scenario("network-retry-without-terminal-proof"); + let finding = snapshot + .findings + .iter() + .find(|finding| finding.finding_id == "autopilot-network-symptom-without-cause") + .expect("the symptom finding must be present"); + assert_eq!( + wire(&finding.confidence), + json!("low"), + "a symptom with no proven cause may never be presented confidently" + ); +} + +#[test] +fn reaching_the_handoff_without_esp_evidence_is_not_a_completed_deployment() { + assert_scenario("completed-without-esp-bundle"); +} + +#[test] +fn one_explicit_shared_identifier_links_autopilot_to_an_esp_session() { + let snapshot = assert_scenario("matching-autopilot-and-esp-session"); + assert_eq!(snapshot.esp_linkage.matched_keys.len(), 1); +} + +#[test] +fn contradictory_identifiers_suppress_every_terminal_claim() { + assert_scenario("conflicting-profile-session-identifiers"); +} + +#[test] +fn a_capped_channel_cannot_support_a_negative_conclusion() { + assert_scenario("incomplete-event-channel"); +} + +#[test] +fn a_malformed_report_section_is_a_coverage_gap_not_an_unknown_schema() { + assert_scenario("malformed-report-section"); +} + +#[test] +fn an_unvalidated_windows_build_withholds_terminal_semantics_but_keeps_the_phase() { + assert_scenario("unknown-windows-schema-version"); +} + +#[test] +fn an_unrecognizable_timezone_downgrades_the_time_basis() { + assert_scenario("invalid-timezone"); +} + +#[test] +fn the_redacted_export_removes_identity_while_preserving_correlation() { + let scenario = "deterministic-identity-redaction"; + let snapshot = assert_scenario(scenario); + let expected = load_json(&scenario_root(scenario).join("expected.json")); + + let redacted = redacted_export_projection(&snapshot); + let text = serde_json::to_string(&wire(&redacted)).expect("redacted export must serialize"); + + for needle in expected["redactionMustNotContain"] + .as_array() + .expect("redactionMustNotContain") + { + let needle = needle.as_str().expect("needle"); + assert!( + !text.contains(needle), + "{scenario}: redacted export still contains {needle:?}" + ); + } + for needle in expected["redactionMustContain"] + .as_array() + .expect("redactionMustContain") + { + let needle = needle.as_str().expect("needle"); + assert!( + text.contains(needle), + "{scenario}: redacted export dropped {needle:?}, which is the only handle back to Intune" + ); + } + + // The masked correlation key must still equal the masked value it came + // from; otherwise redaction would destroy the very link it is exported to + // show. + let masked_key = &redacted.esp_linkage.matched_keys[0].value; + assert!( + masked_key.starts_with('['), + "the correlation key must be masked, got {masked_key}" + ); + assert_eq!( + redacted.esp_linkage.matched_keys, + redacted_export_projection(&snapshot) + .esp_linkage + .matched_keys, + "masking must be a pure function of the value" + ); +} + +// ── Cross-cutting contract ────────────────────────────────────────────────── + +#[test] +fn reduction_is_deterministic_across_runs() { + for scenario in SCENARIOS { + let first = wire(&reduce_autopilot_bundle(&bundle(scenario))); + let second = wire(&reduce_autopilot_bundle(&bundle(scenario))); + assert_eq!(first, second, "{scenario}: reduction must be deterministic"); + } +} + +#[test] +fn the_redacted_export_projection_is_idempotent() { + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + let once = redacted_export_projection(&snapshot); + let twice = redacted_export_projection(&once); + assert_eq!( + wire(&once), + wire(&twice), + "{scenario}: redaction must be idempotent" + ); + } +} + +#[test] +fn the_snapshot_serializes_as_stable_camel_case() { + let snapshot = reduce_autopilot_bundle(&bundle("user-driven-success-through-esp-handoff")); + let value = wire(&snapshot); + for key in [ + "schemaVersion", + "generatedAtUtc", + "capture", + "timezoneState", + "timeBasis", + "identity", + "profile", + "oobe", + "handoff", + "espLinkage", + "phase", + "outcome", + "confidence", + "nextEvidenceRequests", + "observations", + "unclassifiedObservationIds", + "documents", + "conflicts", + "coverage", + "findings", + ] { + assert!(value.get(key).is_some(), "missing top-level key {key}"); + } +} + +/// Every finding must name a concrete next artifact, and every scenario that is +/// not already complete must ask for something. A diagnosis that cannot say +/// what to collect next is not actionable. +#[test] +fn every_finding_recommends_at_least_one_concrete_check() { + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + for finding in &snapshot.findings { + assert!( + !finding.recommended_checks.is_empty(), + "{scenario}: finding {} recommends nothing", + finding.finding_id + ); + } + if snapshot.outcome != cmtraceopen_parser::intune::enrollment::windows::autopilot::AutopilotOutcome::Completed { + assert!( + !snapshot.next_evidence_requests.is_empty(), + "{scenario}: an incomplete diagnosis must name the next artifact" + ); + } + } +} + +/// Observation ids must be unique across a bundle, or a finding's citation +/// becomes ambiguous. +#[test] +fn observation_ids_are_unique_within_a_bundle() { + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + let mut seen = BTreeSet::new(); + for observation in &snapshot.observations { + assert!( + seen.insert(observation.observation_id.as_str()), + "{scenario}: observation id {} was reused", + observation.observation_id + ); + } + } +} + +/// Records from a channel this module does not own must not become Autopilot +/// evidence, however plausible they look. A busy device would otherwise report +/// a worse diagnosis than a quiet one. +#[test] +fn records_from_a_sibling_channel_are_ignored_entirely() { + let mut input = bundle("no-profile-candidate"); + let intruder = std::fs::read_to_string( + scenario_root("no-profile-candidate") + .join("evidence/autopilot-channel/current/autopilot-events.json"), + ) + .expect("evidence is readable") + .replace( + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", + "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", + ); + input.sources.push(AutopilotSourceInput { + artifact_id: "sibling-channel".to_owned(), + family: "managementService".to_owned(), + capture_state: AutopilotCaptureState::Captured, + original_basename: Some("management-service.json".to_owned()), + sanitized_source_path: None, + content: Some(intruder), + ..AutopilotSourceInput::default() + }); + + let snapshot = reduce_autopilot_bundle(&input); + let baseline = reduce_autopilot_bundle(&bundle("no-profile-candidate")); + assert_eq!( + snapshot.observations.len(), + baseline.observations.len(), + "a sibling channel's records must not become Autopilot observations" + ); + assert_eq!(snapshot.outcome, baseline.outcome); + assert!( + snapshot.unclassified_observation_ids.is_empty(), + "an unrelated channel must not even register as unclassified Autopilot evidence" + ); +} + +// ── Golden maintenance ────────────────────────────────────────────────────── + +/// Rewrite every scenario's `findings` golden from the current reducer output. +/// +/// Runs only under `UPDATE_AUTOPILOT_FINDINGS=1`, and is a no-op otherwise so +/// the suite stays read-only in CI. The rewrite touches the `findings` key and +/// nothing else, so the hand-written semantic expectations survive and keep +/// cross-checking the regenerated goldens. +#[test] +fn update_findings_golden() { + if std::env::var("UPDATE_AUTOPILOT_FINDINGS").is_err() { + return; + } + for scenario in SCENARIOS { + let snapshot = reduce_autopilot_bundle(&bundle(scenario)); + let path = scenario_root(scenario).join("expected.json"); + let mut expected = load_json(&path); + // Only `findings` is regenerated. `findingIds` stays hand written on + // purpose: it is the cross-check that catches a regeneration which + // quietly changed which rules fire. + expected["findings"] = wire(&snapshot.findings); + write_json(&path, &expected); + } +} + +fn write_json(path: &Path, value: &Value) { + let text = serde_json::to_string_pretty(value).expect("golden must serialize") + "\n"; + std::fs::write(path, text) + .unwrap_or_else(|error| panic!("{} is writable: {error}", path.display())); +} diff --git a/crates/cmtraceopen-parser/tests/support/mod.rs b/crates/cmtraceopen-parser/tests/support/mod.rs index e74581666..39aebd1b3 100644 --- a/crates/cmtraceopen-parser/tests/support/mod.rs +++ b/crates/cmtraceopen-parser/tests/support/mod.rs @@ -260,10 +260,13 @@ pub fn validate_descriptor_privacy(scenario: &str, scenario_root: &Path, failure for descriptor in ["manifest.json", "expected.json"] { let path = scenario_root.join(descriptor); match std::fs::read_to_string(&path) { - Ok(contents) => { - failures.absorb(privacy_problems(&format!("{scenario}/{descriptor}"), &contents)) + Ok(contents) => failures.absorb(privacy_problems( + &format!("{scenario}/{descriptor}"), + &contents, + )), + Err(error) => { + failures.push(format!("{scenario}: {descriptor} is not readable: {error}")) } - Err(error) => failures.push(format!("{scenario}: {descriptor} is not readable: {error}")), } } } @@ -493,7 +496,9 @@ fn validate_expected_coverage( .unwrap_or_default(); let Some(coverage) = expected["coverage"].as_array() else { - failures.push(format!("{scenario}: expected.json must have a coverage array")); + failures.push(format!( + "{scenario}: expected.json must have a coverage array" + )); return; }; @@ -626,7 +631,8 @@ fn find_windows_sid(contents: &str) -> Option { .split(|c: char| !(c.is_ascii_digit() || c == '-' || c == 'S')) .next() .unwrap_or_default(); - if candidate.matches('-').count() >= 4 && candidate.ends_with(|c: char| c.is_ascii_digit()) { + if candidate.matches('-').count() >= 4 && candidate.ends_with(|c: char| c.is_ascii_digit()) + { return Some(candidate.to_owned()); } } @@ -647,7 +653,19 @@ fn find_email(contents: &str) -> Option { c.is_whitespace() || matches!( c, - '"' | '\'' | ',' | ';' | ':' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '=' + '"' | '\'' + | ',' + | ';' + | ':' + | '<' + | '>' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '=' | '|' ) }) { diff --git a/src-tauri/src/commands/elevation.rs b/src-tauri/src/commands/elevation.rs index 2c5ea901c..660327755 100644 --- a/src-tauri/src/commands/elevation.rs +++ b/src-tauri/src/commands/elevation.rs @@ -111,9 +111,8 @@ impl Serialize for ElevationCommandError { match self { Self::InvalidRequest { reason } => map.serialize_entry("reason", reason)?, Self::Relaunch { source } => map.serialize_entry("source", source)?, - Self::AlreadyInProgress - | Self::TicketUnavailable - | Self::StateDirectoryUnavailable => {} + Self::AlreadyInProgress | Self::TicketUnavailable | Self::StateDirectoryUnavailable => { + } } map.end() } @@ -368,10 +367,9 @@ mod tests { #[test] fn ticket_operations_run_on_the_blocking_pool() { let caller = thread::current().id(); - let worker = tauri::async_runtime::block_on(run_blocking_operation(|| { - thread::current().id() - })) - .expect("blocking worker completes"); + let worker = + tauri::async_runtime::block_on(run_blocking_operation(|| thread::current().id())) + .expect("blocking worker completes"); assert_ne!(worker, caller, "ticket I/O must leave the calling thread"); } @@ -422,10 +420,7 @@ mod tests { .write(true) .open(&path) .expect("open ticket") - .set_times( - FileTimes::new() - .set_modified(SystemTime::now() + Duration::from_secs(60)), - ) + .set_times(FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(60))) .expect("set future mtime"); let abandoned = ticket_for( @@ -561,7 +556,8 @@ mod tests { let json = serde_json::to_value(&error).expect("serialize"); assert_eq!( - json["message"], expected, + json["message"], + expected, "{} lost its message", error.kind() ); diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 6112b8fdb..9ac0e9685 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -697,8 +697,7 @@ mod tests { let dir = create_temp_dir("file-ops-denied"); let locked = dir.join("locked"); fs::create_dir(&locked).expect("create locked dir"); - fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)) - .expect("drop permissions"); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("drop permissions"); let result = list_log_folder(locked.to_string_lossy().to_string()); @@ -727,8 +726,7 @@ mod tests { let dir = create_temp_dir("file-ops-denied-file"); let locked = dir.join("locked.log"); fs::write(&locked, "2026-07-31 log line").expect("write log"); - fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)) - .expect("drop permissions"); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("drop permissions"); let error = super::classify_open_failure( locked.to_string_lossy().as_ref(), @@ -762,7 +760,9 @@ mod tests { // The file opens fine, so the parser's own message survives and no // elevation offer can be produced. - assert!(matches!(error, crate::error::AppError::Internal(reason) if reason == "unsupported format")); + assert!( + matches!(error, crate::error::AppError::Internal(reason) if reason == "unsupported format") + ); } /// A folder reaching the file lane must be classified by its kind, never by diff --git a/src-tauri/src/commands/jamf.rs b/src-tauri/src/commands/jamf.rs index 1f7d18b3c..5d1ef03f2 100644 --- a/src-tauri/src/commands/jamf.rs +++ b/src-tauri/src/commands/jamf.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use crate::error::AppError; use crate::jamf::models::{ - JamfConnectEvent, JamfEnvironment, JamfLogScanResult, JamfPolicyLogResult, - JamfProfilesResult, JamfSelfServiceEvent, + JamfConnectEvent, JamfEnvironment, JamfLogScanResult, JamfPolicyLogResult, JamfProfilesResult, + JamfSelfServiceEvent, }; use crate::jamf::paths; use crate::macos_diag::environment::scan_log_directory; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a5ae3350b..da0bd1439 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -23,9 +23,9 @@ pub mod intune; pub mod intune_bundle; #[cfg(feature = "intune-diagnostics")] pub mod intune_diagnostics; -pub mod known_sources; #[cfg(feature = "macos-diag")] pub mod jamf; +pub mod known_sources; #[cfg(feature = "macos-diag")] pub mod macos_diag; pub mod markers; diff --git a/src-tauri/src/commands/recent_entries.rs b/src-tauri/src/commands/recent_entries.rs index eb4e578e2..1fe1dd628 100644 --- a/src-tauri/src/commands/recent_entries.rs +++ b/src-tauri/src/commands/recent_entries.rs @@ -532,10 +532,7 @@ mod tests { use std::thread; let dir = tempdir().expect("tempdir"); - let state = Arc::new(RecentEntriesState::load( - dir.path().to_path_buf(), - &["log"], - )); + let state = Arc::new(RecentEntriesState::load(dir.path().to_path_buf(), &["log"])); let threads: Vec<_> = (0..8) .map(|index| { diff --git a/src-tauri/src/commands/system_preferences.rs b/src-tauri/src/commands/system_preferences.rs index ad94742e2..fd429b2bd 100644 --- a/src-tauri/src/commands/system_preferences.rs +++ b/src-tauri/src/commands/system_preferences.rs @@ -116,8 +116,7 @@ pub fn set_always_on_top( } if let Some(menu) = app.menu() { - if let Some(MenuItemKind::Check(item)) = - menu.get(crate::menu::MENU_ID_WINDOW_ALWAYS_ON_TOP) + if let Some(MenuItemKind::Check(item)) = menu.get(crate::menu::MENU_ID_WINDOW_ALWAYS_ON_TOP) { let _ = item.set_checked(enabled); } diff --git a/src-tauri/src/elevation/mod.rs b/src-tauri/src/elevation/mod.rs index eeb06dc09..a21d9ee96 100644 --- a/src-tauri/src/elevation/mod.rs +++ b/src-tauri/src/elevation/mod.rs @@ -507,9 +507,10 @@ mod tests { #[test] fn a_camel_case_known_source_request_deserializes() { - let target: RestoreTarget = - serde_json::from_value(serde_json::json!({ "kind": "knownSource", "sourceId": "ccm-logs" })) - .expect("camelCase is the wire contract"); + let target: RestoreTarget = serde_json::from_value( + serde_json::json!({ "kind": "knownSource", "sourceId": "ccm-logs" }), + ) + .expect("camelCase is the wire contract"); assert_eq!( target, @@ -522,8 +523,9 @@ mod tests { #[test] fn the_snake_case_known_source_form_is_rejected_rather_than_tolerated() { // Accepting both would let the contract drift back without a test failing. - let result: Result = - serde_json::from_value(serde_json::json!({ "kind": "knownSource", "source_id": "ccm-logs" })); + let result: Result = serde_json::from_value( + serde_json::json!({ "kind": "knownSource", "source_id": "ccm-logs" }), + ); assert!(result.is_err(), "snake_case must not be accepted"); } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 430256b3f..d0bf0707f 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -266,7 +266,10 @@ mod tests { #[test] fn access_denied_without_a_path_serializes_a_null_path() { - let value = payload(AppError::access_denied(SourceOperation::WorkspaceAction, None)); + let value = payload(AppError::access_denied( + SourceOperation::WorkspaceAction, + None, + )); assert_eq!(value["kind"], "accessDenied"); assert!(value["path"].is_null()); @@ -348,10 +351,7 @@ mod tests { ] { let message = AppError::access_denied(operation, None).to_string(); for os in ["Windows", "macOS", "Linux"] { - assert!( - !message.contains(os), - "{operation:?} names {os}: {message}" - ); + assert!(!message.contains(os), "{operation:?} names {os}: {message}"); } } } diff --git a/src-tauri/src/esp/process.rs b/src-tauri/src/esp/process.rs index 51fd3ec5b..7a2378470 100644 --- a/src-tauri/src/esp/process.rs +++ b/src-tauri/src/esp/process.rs @@ -442,8 +442,7 @@ fn sanitize_json_command_value(value: &mut serde_json::Value) -> bool { fn redact_cross_element_string_secrets(values: &mut [serde_json::Value]) -> bool { let mut changed = false; for index in 0..values.len().saturating_sub(1) { - let (Some(prefix), Some(candidate)) = - (values[index].as_str(), values[index + 1].as_str()) + let (Some(prefix), Some(candidate)) = (values[index].as_str(), values[index + 1].as_str()) else { continue; }; diff --git a/src-tauri/src/esp/registry.rs b/src-tauri/src/esp/registry.rs index 7859682df..8a227bc87 100644 --- a/src-tauri/src/esp/registry.rs +++ b/src-tauri/src/esp/registry.rs @@ -743,7 +743,11 @@ fn node_cache_contains_hardware_identity(entry: &RegistrySnapshotKey) -> bool { }) } -fn registry_sensitivity(key: &str, value_name: &str, value: &EspObservationValue) -> EspSensitivity { +fn registry_sensitivity( + key: &str, + value_name: &str, + value: &EspObservationValue, +) -> EspSensitivity { let path_sensitivity = registry_path_sensitivity(key); if path_sensitivity != EspSensitivity::Public { return path_sensitivity; diff --git a/src-tauri/src/esp/system.rs b/src-tauri/src/esp/system.rs index 848edf50f..3589881af 100644 --- a/src-tauri/src/esp/system.rs +++ b/src-tauri/src/esp/system.rs @@ -1573,6 +1573,9 @@ mod windows_provider { use windows::core::{BSTR, HRESULT, PCWSTR}; use windows::Win32::Foundation::{CloseHandle, E_ACCESSDENIED, HANDLE, RPC_E_CHANGED_MODE}; + use windows::Win32::NetworkManagement::NetManagement::{ + NetFreeAadJoinInformation, NetGetAadJoinInformation, + }; use windows::Win32::Security::{ GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, }; @@ -1581,9 +1584,6 @@ mod windows_provider { CoSetProxyBlanket, CoUninitialize, CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, EOAC_NONE, RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE, }; - use windows::Win32::NetworkManagement::NetManagement::{ - NetFreeAadJoinInformation, NetGetAadJoinInformation, - }; use windows::Win32::System::SystemInformation::GetSystemWindowsDirectoryW; use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; use windows::Win32::System::Variant::{VariantClear, VariantToString, VARIANT}; diff --git a/src-tauri/src/graph_api/esp.rs b/src-tauri/src/graph_api/esp.rs index 068d05e63..9c547a346 100644 --- a/src-tauri/src/graph_api/esp.rs +++ b/src-tauri/src/graph_api/esp.rs @@ -2556,12 +2556,8 @@ mod overlay_tests { let mut request = base_request(); request.app_ids = vec![APP_GUID.to_string()]; - let overlay = fetch_esp_graph_overlay( - &provider, - &request, - &NeverCancelled, - "2026-01-01T00:00:00Z", - ); + let overlay = + fetch_esp_graph_overlay(&provider, &request, &NeverCancelled, "2026-01-01T00:00:00Z"); assert_eq!(overlay.device_match.status, GraphSectionStatus::Available); assert_eq!(overlay.apps.status, GraphSectionStatus::Available); @@ -2581,12 +2577,8 @@ mod overlay_tests { }; let request = base_request(); // app_ids and workload_ids left empty - let overlay = fetch_esp_graph_overlay( - &provider, - &request, - &NeverCancelled, - "2026-01-01T00:00:00Z", - ); + let overlay = + fetch_esp_graph_overlay(&provider, &request, &NeverCancelled, "2026-01-01T00:00:00Z"); assert_eq!(overlay.device_match.status, GraphSectionStatus::Available); assert_eq!(overlay.apps.status, GraphSectionStatus::Skipped); diff --git a/src-tauri/src/graph_api/models.rs b/src-tauri/src/graph_api/models.rs index 7df31aa33..0c3ea0bab 100644 --- a/src-tauri/src/graph_api/models.rs +++ b/src-tauri/src/graph_api/models.rs @@ -199,10 +199,7 @@ pub fn classify_graph_permission_candidate( // object id is absent or unverifiable on either side so a same-tenant, // different-account token can never replace the connected token — even when // the optional WAM `UserName` (UPN) is missing for federated/guest accounts. - let account_matches = match ( - current.object_id.as_deref(), - candidate.object_id.as_deref(), - ) { + let account_matches = match (current.object_id.as_deref(), candidate.object_id.as_deref()) { (Some(current_oid), Some(candidate_oid)) => current_oid.eq_ignore_ascii_case(candidate_oid), _ => false, }; diff --git a/src-tauri/src/intune/evtx_parser.rs b/src-tauri/src/intune/evtx_parser.rs index 38cc55887..89fe6c507 100644 --- a/src-tauri/src/intune/evtx_parser.rs +++ b/src-tauri/src/intune/evtx_parser.rs @@ -1747,8 +1747,7 @@ mod tests { // More than the per-element cap is rejected before quick-xml's O(n^2) // duplicate-attribute check (RUSTSEC-2026-0194) can blow up. - let oversized = - esp_record_xml_with_root_attributes(MAX_ESP_XML_ATTRIBUTES_PER_ELEMENT + 1); + let oversized = esp_record_xml_with_root_attributes(MAX_ESP_XML_ATTRIBUTES_PER_ELEMENT + 1); let started = std::time::Instant::now(); assert!( parse_esp_event_xml(&oversized, "attr-cap.evtx", Some(1), None, "Unknown").is_none(), diff --git a/src-tauri/src/jamf/connect.rs b/src-tauri/src/jamf/connect.rs index eb30e8cdb..cd8b2ae1e 100644 --- a/src-tauri/src/jamf/connect.rs +++ b/src-tauri/src/jamf/connect.rs @@ -31,9 +31,7 @@ fn user_regex() -> &'static Regex { fn idp_regex() -> &'static Regex { use std::sync::OnceLock; static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"provider=(?P\w+)").expect("static regex must compile") - }) + RE.get_or_init(|| Regex::new(r"provider=(?P\w+)").expect("static regex must compile")) } pub fn parse_connect_log_impl(path: &Path) -> Result, AppError> { diff --git a/src-tauri/src/jamf/detect.rs b/src-tauri/src/jamf/detect.rs index 3246b86ad..f996991d5 100644 --- a/src-tauri/src/jamf/detect.rs +++ b/src-tauri/src/jamf/detect.rs @@ -206,7 +206,11 @@ fn read_jss_url() -> Option { return None; } let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if url.is_empty() { None } else { Some(url) } + if url.is_empty() { + None + } else { + Some(url) + } } fn read_jamf_connect_version() -> Option { @@ -221,7 +225,11 @@ fn read_jamf_connect_version() -> Option { return None; } let v = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if v.is_empty() { None } else { Some(v) } + if v.is_empty() { + None + } else { + Some(v) + } } fn read_jamf_connect_idp() -> Option { @@ -230,14 +238,11 @@ fn read_jamf_connect_idp() -> Option { continue; } let print_key = format!("Print :{key}"); - let output = match output_with_timeout( - PLISTBUDDY, - &["-c", &print_key, plist], - PLISTBUDDY_TIMEOUT, - ) { - Some(o) => o, - None => continue, - }; + let output = + match output_with_timeout(PLISTBUDDY, &["-c", &print_key, plist], PLISTBUDDY_TIMEOUT) { + Some(o) => o, + None => continue, + }; if !output.status.success() { continue; } @@ -264,6 +269,10 @@ fn build_summary( } else { "" }; - let log = if dirs.jamf_log { "" } else { " · jamf.log missing" }; + let log = if dirs.jamf_log { + "" + } else { + " · jamf.log missing" + }; format!("JAMF binary detected ({v}){connect}{log}") } diff --git a/src-tauri/src/jamf/mod.rs b/src-tauri/src/jamf/mod.rs index b0c5bf102..89b40c7ae 100644 --- a/src-tauri/src/jamf/mod.rs +++ b/src-tauri/src/jamf/mod.rs @@ -1,9 +1,9 @@ +pub mod connect; +pub mod detect; pub mod models; pub mod paths; -pub mod text; -pub mod time; -pub mod detect; pub mod policy_log; -pub mod self_service; -pub mod connect; pub mod profiles; +pub mod self_service; +pub mod text; +pub mod time; diff --git a/src-tauri/src/jamf/models.rs b/src-tauri/src/jamf/models.rs index cd2fa351a..106017621 100644 --- a/src-tauri/src/jamf/models.rs +++ b/src-tauri/src/jamf/models.rs @@ -31,8 +31,8 @@ pub struct JamfDirectoryStatus { pub jamf_log: bool, pub jamf_app_support: bool, pub jamf_receipts: bool, - pub jamf_user_logs: bool, // ~/Library/Logs/JAMF - pub self_service_log: bool, // ~/Library/Logs/JAMF/selfservice.log + pub jamf_user_logs: bool, // ~/Library/Logs/JAMF + pub self_service_log: bool, // ~/Library/Logs/JAMF/selfservice.log pub connect_log: bool, pub connect_user_logs: bool, } diff --git a/src-tauri/src/jamf/paths.rs b/src-tauri/src/jamf/paths.rs index 422bf1cd7..fb93b53f3 100644 --- a/src-tauri/src/jamf/paths.rs +++ b/src-tauri/src/jamf/paths.rs @@ -36,7 +36,10 @@ pub const JAMF_CONNECT_IDP_SOURCES: &[(&str, &str)] = &[ ), ("/Library/Preferences/com.jamf.connect.plist", "Provider"), // Legacy: the key this module originally queried. - ("/Library/Preferences/com.jamf.connect.plist", "OIDCProvider"), + ( + "/Library/Preferences/com.jamf.connect.plist", + "OIDCProvider", + ), ]; /// Returns the installed JAMF Connect app bundle, preferring the modern name. diff --git a/src-tauri/src/jamf/policy_log.rs b/src-tauri/src/jamf/policy_log.rs index 61825d3a5..8d91d2949 100644 --- a/src-tauri/src/jamf/policy_log.rs +++ b/src-tauri/src/jamf/policy_log.rs @@ -234,7 +234,11 @@ fn classify( return ( JamfPolicyTrigger::Other("install".to_string()), None, - Some(pkg.trim_end_matches("...").trim_end_matches('.').to_string()), + Some( + pkg.trim_end_matches("...") + .trim_end_matches('.') + .to_string(), + ), JamfPolicyResult::InProgress, ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0ba504a22..9d74bbfd2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,9 +16,9 @@ pub mod intune; #[cfg(debug_assertions)] mod ipc_bridge; #[cfg(feature = "macos-diag")] -pub mod macos_diag; -#[cfg(feature = "macos-diag")] pub mod jamf; +#[cfg(feature = "macos-diag")] +pub mod macos_diag; mod menu; pub use cmtraceopen_parser::models; pub mod parser; diff --git a/src-tauri/src/macos_diag/unified_log.rs b/src-tauri/src/macos_diag/unified_log.rs index 5bb678d26..5b672b3f5 100644 --- a/src-tauri/src/macos_diag/unified_log.rs +++ b/src-tauri/src/macos_diag/unified_log.rs @@ -266,9 +266,7 @@ mod tests { fn test_parse_ndjson_log_entries_capped() { let line = r#"{"timestamp":"2024-01-01 00:00:00.000000-0000","processImagePath":"/usr/bin/test","messageType":"Info","eventMessage":"msg","processID":1}"#; // Create 10 lines - let input = std::iter::repeat_n(line, 10) - .collect::>() - .join("\n"); + let input = std::iter::repeat_n(line, 10).collect::>().join("\n"); let (entries, total, capped) = parse_ndjson_log_entries(&input, 3); assert_eq!(entries.len(), 3); diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index f2594205f..0591098f9 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1338,7 +1338,10 @@ fn recent_entry_hash(entry: &RecentEntry) -> String { /// (concurrent pushes, or a prune dropping an earlier row) — so the hash lets /// `enrich_recent_payload` detect a stale index before acting on it. fn recent_menu_id(index: usize, entry: &RecentEntry) -> String { - format!("{RECENT_MENU_ID_PREFIX}{index}.{}", recent_entry_hash(entry)) + format!( + "{RECENT_MENU_ID_PREFIX}{index}.{}", + recent_entry_hash(entry) + ) } /// Inverse of `recent_menu_id`: splits `recent.{index}.{hash}` into its parts. @@ -2345,10 +2348,7 @@ mod tests { opened_at_unix_ms: 0, }; - assert_eq!( - recent_entry_label(&entry), - "IME — bundle-01 (Log Explorer)" - ); + assert_eq!(recent_entry_label(&entry), "IME — bundle-01 (Log Explorer)"); } #[test] @@ -2390,7 +2390,8 @@ mod tests { assert_eq!(hash.len(), 16, "expected a full 64-bit digest"); assert!( - hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), + hash.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), "expected lowercase hex, got {hash}" ); } diff --git a/src-tauri/src/sysmon/evtx_parser.rs b/src-tauri/src/sysmon/evtx_parser.rs index 68b18b912..dc74b4a96 100644 --- a/src-tauri/src/sysmon/evtx_parser.rs +++ b/src-tauri/src/sysmon/evtx_parser.rs @@ -274,10 +274,7 @@ pub fn build_summary( // for this event. String-only events can still update earliest/latest // even when other events had numeric timestamps. let ts = event.timestamp.as_str(); - if earliest_ts - .as_deref() - .is_none_or(|existing| ts < existing) - { + if earliest_ts.as_deref().is_none_or(|existing| ts < existing) { earliest_ts = Some(event.timestamp.clone()); } if latest_ts.as_deref().is_none_or(|existing| ts > existing) { diff --git a/src-tauri/tests/esp_diagnostics_sources.rs b/src-tauri/tests/esp_diagnostics_sources.rs index fcb6e10af..54aff2fca 100644 --- a/src-tauri/tests/esp_diagnostics_sources.rs +++ b/src-tauri/tests/esp_diagnostics_sources.rs @@ -62,11 +62,10 @@ use cmtraceopen_parser::esp::{ EspArtifactCoverage, EspArtifactStatus, EspDiagnosticsReducer, EspDiagnosticsSnapshot, EspElevationState, EspEvidenceProvenance, EspEvidenceRecord, EspEvidenceRef, EspGraphObservation, EspGraphObservationSection, EspHardwareEvidence, EspImeObservation, - EspObservationContext, - EspObservationValue, EspParseState, EspProcessObservation, EspRegistryObservation, - EspRegistryProvenance, EspScope, EspSensitivity, EspSourceAccessState, EspSourceKind, - EspSystemFact, EspSystemObservation, EspTimestamp, EspTimestampKind, GraphApiVersion, - MAX_EVIDENCE_IDENTITY_SOURCES, MAX_RETAINED_EVIDENCE_RECORDS, + EspObservationContext, EspObservationValue, EspParseState, EspProcessObservation, + EspRegistryObservation, EspRegistryProvenance, EspScope, EspSensitivity, EspSourceAccessState, + EspSourceKind, EspSystemFact, EspSystemObservation, EspTimestamp, EspTimestampKind, + GraphApiVersion, MAX_EVIDENCE_IDENTITY_SOURCES, MAX_RETAINED_EVIDENCE_RECORDS, }; use tempfile::tempdir; @@ -7884,9 +7883,11 @@ fn bundle_legacy_fallback_is_depth_extension_and_basename_allowlisted() { Some("Legacy Profile") ); assert!(snapshot.raw_evidence.iter().all(|record| { - record.provenance.file_path.as_deref().is_none_or(|path| { - !path.ends_with("arbitrary.json") && !path.ends_with("ignored.exe") - }) + record + .provenance + .file_path + .as_deref() + .is_none_or(|path| !path.ends_with("arbitrary.json") && !path.ends_with("ignored.exe")) })); } diff --git a/src-tauri/tests/jamf_environment.rs b/src-tauri/tests/jamf_environment.rs index e66b50f8d..6fb11c576 100644 --- a/src-tauri/tests/jamf_environment.rs +++ b/src-tauri/tests/jamf_environment.rs @@ -9,7 +9,10 @@ fn collect_environment_does_not_panic() { // An installed binary does not guarantee `jamf version` succeeds (it can // fail or time out), so only the inverse is a real invariant. if !env.jamf_installed { - assert!(env.jamf_version.is_none(), "version reported without a binary"); + assert!( + env.jamf_version.is_none(), + "version reported without a binary" + ); } } diff --git a/src-tauri/tests/jamf_ipc_contract.rs b/src-tauri/tests/jamf_ipc_contract.rs index 53d41267c..4a356e1f6 100644 --- a/src-tauri/tests/jamf_ipc_contract.rs +++ b/src-tauri/tests/jamf_ipc_contract.rs @@ -45,7 +45,10 @@ fn policy_trigger_data_variants_carry_a_value_field() { #[test] fn policy_result_matches_the_typescript_union() { assert_eq!(json(&JamfPolicyResult::Success), r#"{"type":"success"}"#); - assert_eq!(json(&JamfPolicyResult::InProgress), r#"{"type":"inProgress"}"#); + assert_eq!( + json(&JamfPolicyResult::InProgress), + r#"{"type":"inProgress"}"# + ); assert_eq!(json(&JamfPolicyResult::Unknown), r#"{"type":"unknown"}"#); assert_eq!( json(&JamfPolicyResult::Failure("Error running recon".into())), diff --git a/src-tauri/tests/jamf_known_sources.rs b/src-tauri/tests/jamf_known_sources.rs index e770c9979..4c43778d0 100644 --- a/src-tauri/tests/jamf_known_sources.rs +++ b/src-tauri/tests/jamf_known_sources.rs @@ -22,7 +22,10 @@ fn jamf_known_sources_present() { let all = build_known_log_sources(); let jamf = jamf_sources(&all); let ids: Vec<&str> = jamf.iter().map(|s| s.id.as_str()).collect(); - assert!(ids.contains(&"macos-jamf-log"), "missing macos-jamf-log: {ids:?}"); + assert!( + ids.contains(&"macos-jamf-log"), + "missing macos-jamf-log: {ids:?}" + ); assert!(ids.contains(&"macos-jamf-app-support-logs")); assert!(ids.contains(&"macos-jamf-receipts")); assert!(ids.contains(&"macos-jamf-self-service-log")); @@ -40,11 +43,11 @@ fn jamf_log_default_file_is_jamf_log() { .iter() .find(|s| s.id == "macos-jamf-log") .expect("macos-jamf-log should exist"); - let intent = entry.default_file_intent.as_ref().expect("should have default file intent"); - assert!(intent - .preferred_file_names - .iter() - .any(|n| n == "jamf.log")); + let intent = entry + .default_file_intent + .as_ref() + .expect("should have default file intent"); + assert!(intent.preferred_file_names.iter().any(|n| n == "jamf.log")); } #[cfg(not(target_os = "macos"))] diff --git a/src-tauri/tests/jamf_parser_robustness.rs b/src-tauri/tests/jamf_parser_robustness.rs index 7bed41c27..1bba452c9 100644 --- a/src-tauri/tests/jamf_parser_robustness.rs +++ b/src-tauri/tests/jamf_parser_robustness.rs @@ -32,7 +32,11 @@ fn policy_log_survives_invalid_utf8_and_keeps_all_lines() { let result = parse_policy_log_impl(&path).expect("must not fail on invalid UTF-8"); assert_eq!(result.total_lines, 3); - assert_eq!(result.events.len(), 3, "every line must still yield an event"); + assert_eq!( + result.events.len(), + 3, + "every line must still yield an event" + ); assert_eq!(result.unparsed_lines, 0); let names: Vec<&str> = result @@ -42,7 +46,11 @@ fn policy_log_survives_invalid_utf8_and_keeps_all_lines() { .collect(); assert_eq!(names[0], "First"); assert_eq!(names[2], "Third"); - assert!(names[1].starts_with("Ba"), "decoded lossily: {:?}", names[1]); + assert!( + names[1].starts_with("Ba"), + "decoded lossily: {:?}", + names[1] + ); let _ = std::fs::remove_file(&path); } @@ -77,7 +85,10 @@ fn policy_log_tolerates_a_truncated_final_line() { let result = parse_policy_log_impl(&path).expect("parse"); assert_eq!(result.total_lines, 2); assert_eq!(result.events.len(), 1); - assert_eq!(result.unparsed_lines, 1, "the partial line is counted, not fatal"); + assert_eq!( + result.unparsed_lines, 1, + "the partial line is counted, not fatal" + ); let _ = std::fs::remove_file(&path); } @@ -190,9 +201,11 @@ fn an_over_long_line_does_not_disturb_later_offsets() { fn missing_files_are_not_errors_except_for_the_policy_log() { // Self Service / Connect may legitimately be absent; jamf.log going missing // is a condition worth surfacing. - assert!(parse_self_service_log_impl(Path::new("/nonexistent/ss.log")) - .expect("absent Self Service log is empty, not an error") - .is_empty()); + assert!( + parse_self_service_log_impl(Path::new("/nonexistent/ss.log")) + .expect("absent Self Service log is empty, not an error") + .is_empty() + ); assert!(parse_connect_log_impl(Path::new("/nonexistent/jc.log")) .expect("absent Connect log is empty, not an error") .is_empty()); diff --git a/src-tauri/tests/jamf_policy_log_parsing.rs b/src-tauri/tests/jamf_policy_log_parsing.rs index fb1c33d53..aac7fb35d 100644 --- a/src-tauri/tests/jamf_policy_log_parsing.rs +++ b/src-tauri/tests/jamf_policy_log_parsing.rs @@ -77,7 +77,10 @@ fn classifies_jss_connectivity_failure() { #[test] fn unparsed_lines_counted() { let result = parse_policy_log_impl(Path::new(BASIC)).expect("parse"); - assert_eq!(result.total_lines, result.events.len() + result.unparsed_lines); + assert_eq!( + result.total_lines, + result.events.len() + result.unparsed_lines + ); } #[test] @@ -129,8 +132,10 @@ Wed Jul 22 20:10:32 host jamf[6003]: Successfully installed Zscaler-osx-4.5.2.31 let installing = result .events .iter() - .find(|e| matches!(&e.result, JamfPolicyResult::InProgress) - && e.policy_name.as_deref() == Some("Zscaler-osx-4.5.2.312-installer.pkg")) + .find(|e| { + matches!(&e.result, JamfPolicyResult::InProgress) + && e.policy_name.as_deref() == Some("Zscaler-osx-4.5.2.312-installer.pkg") + }) .expect("the Installing line should name the package"); assert!(matches!(&installing.trigger, JamfPolicyTrigger::Other(k) if k == "install")); diff --git a/src-tauri/tests/jamf_real_fixtures.rs b/src-tauri/tests/jamf_real_fixtures.rs index 575b90168..efe9bd60d 100644 --- a/src-tauri/tests/jamf_real_fixtures.rs +++ b/src-tauri/tests/jamf_real_fixtures.rs @@ -117,7 +117,10 @@ fn self_service_log_yields_user_actions() { let Some(dir) = fixture_dir() else { return }; let events = parse_self_service_log_impl(&dir.join("logs/selfservice.log")).expect("parse"); - assert!(!events.is_empty(), "real selfservice.log produced no events"); + assert!( + !events.is_empty(), + "real selfservice.log produced no events" + ); assert!( events.iter().any(|e| e.action == "triggerPolicy"), "capture is known to contain Self Service-initiated installs" @@ -170,8 +173,8 @@ fn captured_profiles_xml_parses_and_filters_to_jamf() { profiles.len() ); - let filtered = app_lib::jamf::profiles::filter_jamf_profiles_impl(profiles, None) - .expect("filter"); + let filtered = + app_lib::jamf::profiles::filter_jamf_profiles_impl(profiles, None).expect("filter"); assert!( !filtered.profiles.is_empty(), "payload-prefix matching found no JAMF profiles in a JAMF capture" diff --git a/src-tauri/tests/jamf_self_service_log_parsing.rs b/src-tauri/tests/jamf_self_service_log_parsing.rs index ada433488..b33283c99 100644 --- a/src-tauri/tests/jamf_self_service_log_parsing.rs +++ b/src-tauri/tests/jamf_self_service_log_parsing.rs @@ -28,7 +28,10 @@ fn parses_real_selfservice_log_shapes() { // API chatter is one action with the endpoint as the item. assert_eq!(events[4].action, "request"); - assert_eq!(events[4].item_name.as_deref(), Some("updateDevicePushToken")); + assert_eq!( + events[4].item_name.as_deref(), + Some("updateDevicePushToken") + ); assert_eq!(events[7].action, "warning"); assert!(events[7] @@ -37,7 +40,10 @@ fn parses_real_selfservice_log_shapes() { .is_some_and(|m| m.starts_with("A customized icon"))); // Binary requests are the user-side operations. - let triggers: Vec<&_> = events.iter().filter(|e| e.action == "triggerPolicy").collect(); + let triggers: Vec<&_> = events + .iter() + .filter(|e| e.action == "triggerPolicy") + .collect(); assert_eq!(triggers.len(), 2); assert_eq!(events.iter().filter(|e| e.action == "doRecon").count(), 1); } From 7cd55b9394ec2e5858b87f9b89519dc1c01467bd Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 7 Aug 2026 22:23:05 -0400 Subject: [PATCH 3/3] fix: adapt PR #450 to current main after merge - Revert rustfmt-only churn the automated agent introduced in files unrelated to the Autopilot parser (jamf, elevation, error, collector, package_state, skeleton contract tests) back to main's formatting. - Wire the new NormalizedWindowsEvent.event_version field into the test constructors main added since (microsoft_store, compliance). Co-Authored-By: Claude Fable 5 --- .../src/collector/profile.rs | 5 +-- .../apps/windows/microsoft_store/reducer.rs | 1 + .../apps/windows/microsoft_store/rules.rs | 1 + .../device/windows/compliance/reducer.rs | 1 + .../device/windows/compliance/sources.rs | 1 + .../src/intune/normalized.rs | 4 +-- .../company_portal/package_state/mod.rs | 13 ++++---- .../tests/intune_skeleton_contract.rs | 33 +++++++++---------- .../cmtraceopen-parser/tests/support/mod.rs | 30 ++++------------- src-tauri/src/commands/elevation.rs | 20 ++++++----- src-tauri/src/commands/file_ops.rs | 10 +++--- src-tauri/src/commands/jamf.rs | 4 +-- src-tauri/src/commands/mod.rs | 2 +- src-tauri/src/elevation/mod.rs | 12 +++---- src-tauri/src/error.rs | 10 +++--- src-tauri/src/jamf/connect.rs | 4 ++- src-tauri/src/jamf/detect.rs | 31 +++++++---------- src-tauri/src/jamf/mod.rs | 10 +++--- src-tauri/src/jamf/models.rs | 4 +-- src-tauri/src/jamf/paths.rs | 5 +-- src-tauri/src/jamf/policy_log.rs | 6 +--- src-tauri/src/lib.rs | 4 +-- src-tauri/tests/jamf_environment.rs | 5 +-- src-tauri/tests/jamf_ipc_contract.rs | 5 +-- src-tauri/tests/jamf_known_sources.rs | 15 ++++----- src-tauri/tests/jamf_parser_robustness.rs | 25 ++++---------- src-tauri/tests/jamf_policy_log_parsing.rs | 11 ++----- src-tauri/tests/jamf_real_fixtures.rs | 9 ++--- .../tests/jamf_self_service_log_parsing.rs | 10 ++---- 29 files changed, 112 insertions(+), 179 deletions(-) diff --git a/crates/cmtraceopen-parser/src/collector/profile.rs b/crates/cmtraceopen-parser/src/collector/profile.rs index c21ed2b2d..3590a7624 100644 --- a/crates/cmtraceopen-parser/src/collector/profile.rs +++ b/crates/cmtraceopen-parser/src/collector/profile.rs @@ -209,10 +209,7 @@ mod tests { fn filter_by_macos_jamf_yields_jamf_items_only() { let mut profile = CollectionProfile::embedded(); profile.filter_by_families(&["macos-jamf".to_string()]); - assert!( - profile.total_items() >= 7, - "should have at least 5 logs + 2 commands" - ); + assert!(profile.total_items() >= 7, "should have at least 5 logs + 2 commands"); for item in &profile.logs { assert_eq!(item.family, "macos-jamf"); } diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs index 927b7ea1c..6d0b9564f 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs @@ -858,6 +858,7 @@ mod tests { keywords: None, record_id: Some(record), activity_id: None, + event_version: None, named_data: named_data .iter() .map(|(name, value)| IntuneNamedValue { diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs index b58d660a4..a39f696d1 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/rules.rs @@ -575,6 +575,7 @@ mod tests { keywords: None, record_id: Some(1), activity_id: None, + event_version: None, named_data: named_data .iter() .map(|(name, value)| IntuneNamedValue { diff --git a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs index ec546207b..3daa89da6 100644 --- a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs +++ b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/reducer.rs @@ -797,6 +797,7 @@ mod tests { keywords: None, record_id: None, activity_id: None, + event_version: None, named_data: vec![ crate::intune::evidence::IntuneNamedValue { name: "SettingUri".to_owned(), diff --git a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs index f5c5b60c7..a3f7f24ea 100644 --- a/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs @@ -671,6 +671,7 @@ mod tests { keywords: None, record_id: None, activity_id: None, + event_version: None, named_data: data .iter() .map(|(name, value)| IntuneNamedValue { diff --git a/crates/cmtraceopen-parser/src/intune/normalized.rs b/crates/cmtraceopen-parser/src/intune/normalized.rs index 807c816a4..c8b951474 100644 --- a/crates/cmtraceopen-parser/src/intune/normalized.rs +++ b/crates/cmtraceopen-parser/src/intune/normalized.rs @@ -113,8 +113,8 @@ pub struct NormalizedSettingReport { mod tests { use super::*; use crate::intune::evidence::{ - IntuneAccessState, IntuneEvidenceRef, IntuneParseState, IntuneProvenance, - IntuneSensitivity, IntuneSourceKind, + IntuneAccessState, IntuneEvidenceRef, IntuneParseState, IntuneProvenance, IntuneSensitivity, + IntuneSourceKind, }; fn context() -> IntuneObservationContext { diff --git a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs index 87ef8bc7e..43e5b7448 100644 --- a/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs +++ b/crates/cmtraceopen-parser/src/intune/portal/windows/company_portal/package_state/mod.rs @@ -59,13 +59,12 @@ pub fn parse_package_state_capture(json: &str) -> Result failures.absorb(privacy_problems( - &format!("{scenario}/{descriptor}"), - &contents, - )), - Err(error) => { - failures.push(format!("{scenario}: {descriptor} is not readable: {error}")) + Ok(contents) => { + failures.absorb(privacy_problems(&format!("{scenario}/{descriptor}"), &contents)) } + Err(error) => failures.push(format!("{scenario}: {descriptor} is not readable: {error}")), } } } @@ -496,9 +493,7 @@ fn validate_expected_coverage( .unwrap_or_default(); let Some(coverage) = expected["coverage"].as_array() else { - failures.push(format!( - "{scenario}: expected.json must have a coverage array" - )); + failures.push(format!("{scenario}: expected.json must have a coverage array")); return; }; @@ -631,8 +626,7 @@ fn find_windows_sid(contents: &str) -> Option { .split(|c: char| !(c.is_ascii_digit() || c == '-' || c == 'S')) .next() .unwrap_or_default(); - if candidate.matches('-').count() >= 4 && candidate.ends_with(|c: char| c.is_ascii_digit()) - { + if candidate.matches('-').count() >= 4 && candidate.ends_with(|c: char| c.is_ascii_digit()) { return Some(candidate.to_owned()); } } @@ -653,19 +647,7 @@ fn find_email(contents: &str) -> Option { c.is_whitespace() || matches!( c, - '"' | '\'' - | ',' - | ';' - | ':' - | '<' - | '>' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '=' + '"' | '\'' | ',' | ';' | ':' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '=' | '|' ) }) { diff --git a/src-tauri/src/commands/elevation.rs b/src-tauri/src/commands/elevation.rs index 660327755..2c5ea901c 100644 --- a/src-tauri/src/commands/elevation.rs +++ b/src-tauri/src/commands/elevation.rs @@ -111,8 +111,9 @@ impl Serialize for ElevationCommandError { match self { Self::InvalidRequest { reason } => map.serialize_entry("reason", reason)?, Self::Relaunch { source } => map.serialize_entry("source", source)?, - Self::AlreadyInProgress | Self::TicketUnavailable | Self::StateDirectoryUnavailable => { - } + Self::AlreadyInProgress + | Self::TicketUnavailable + | Self::StateDirectoryUnavailable => {} } map.end() } @@ -367,9 +368,10 @@ mod tests { #[test] fn ticket_operations_run_on_the_blocking_pool() { let caller = thread::current().id(); - let worker = - tauri::async_runtime::block_on(run_blocking_operation(|| thread::current().id())) - .expect("blocking worker completes"); + let worker = tauri::async_runtime::block_on(run_blocking_operation(|| { + thread::current().id() + })) + .expect("blocking worker completes"); assert_ne!(worker, caller, "ticket I/O must leave the calling thread"); } @@ -420,7 +422,10 @@ mod tests { .write(true) .open(&path) .expect("open ticket") - .set_times(FileTimes::new().set_modified(SystemTime::now() + Duration::from_secs(60))) + .set_times( + FileTimes::new() + .set_modified(SystemTime::now() + Duration::from_secs(60)), + ) .expect("set future mtime"); let abandoned = ticket_for( @@ -556,8 +561,7 @@ mod tests { let json = serde_json::to_value(&error).expect("serialize"); assert_eq!( - json["message"], - expected, + json["message"], expected, "{} lost its message", error.kind() ); diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 9ac0e9685..6112b8fdb 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -697,7 +697,8 @@ mod tests { let dir = create_temp_dir("file-ops-denied"); let locked = dir.join("locked"); fs::create_dir(&locked).expect("create locked dir"); - fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("drop permissions"); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)) + .expect("drop permissions"); let result = list_log_folder(locked.to_string_lossy().to_string()); @@ -726,7 +727,8 @@ mod tests { let dir = create_temp_dir("file-ops-denied-file"); let locked = dir.join("locked.log"); fs::write(&locked, "2026-07-31 log line").expect("write log"); - fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("drop permissions"); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)) + .expect("drop permissions"); let error = super::classify_open_failure( locked.to_string_lossy().as_ref(), @@ -760,9 +762,7 @@ mod tests { // The file opens fine, so the parser's own message survives and no // elevation offer can be produced. - assert!( - matches!(error, crate::error::AppError::Internal(reason) if reason == "unsupported format") - ); + assert!(matches!(error, crate::error::AppError::Internal(reason) if reason == "unsupported format")); } /// A folder reaching the file lane must be classified by its kind, never by diff --git a/src-tauri/src/commands/jamf.rs b/src-tauri/src/commands/jamf.rs index 5d1ef03f2..1f7d18b3c 100644 --- a/src-tauri/src/commands/jamf.rs +++ b/src-tauri/src/commands/jamf.rs @@ -2,8 +2,8 @@ use std::path::PathBuf; use crate::error::AppError; use crate::jamf::models::{ - JamfConnectEvent, JamfEnvironment, JamfLogScanResult, JamfPolicyLogResult, JamfProfilesResult, - JamfSelfServiceEvent, + JamfConnectEvent, JamfEnvironment, JamfLogScanResult, JamfPolicyLogResult, + JamfProfilesResult, JamfSelfServiceEvent, }; use crate::jamf::paths; use crate::macos_diag::environment::scan_log_directory; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e470644be..0c69b0d47 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -23,9 +23,9 @@ pub mod intune; pub mod intune_bundle; #[cfg(feature = "intune-diagnostics")] pub mod intune_diagnostics; +pub mod known_sources; #[cfg(feature = "macos-diag")] pub mod jamf; -pub mod known_sources; #[cfg(feature = "macos-diag")] pub mod macos_diag; pub mod markers; diff --git a/src-tauri/src/elevation/mod.rs b/src-tauri/src/elevation/mod.rs index a21d9ee96..eeb06dc09 100644 --- a/src-tauri/src/elevation/mod.rs +++ b/src-tauri/src/elevation/mod.rs @@ -507,10 +507,9 @@ mod tests { #[test] fn a_camel_case_known_source_request_deserializes() { - let target: RestoreTarget = serde_json::from_value( - serde_json::json!({ "kind": "knownSource", "sourceId": "ccm-logs" }), - ) - .expect("camelCase is the wire contract"); + let target: RestoreTarget = + serde_json::from_value(serde_json::json!({ "kind": "knownSource", "sourceId": "ccm-logs" })) + .expect("camelCase is the wire contract"); assert_eq!( target, @@ -523,9 +522,8 @@ mod tests { #[test] fn the_snake_case_known_source_form_is_rejected_rather_than_tolerated() { // Accepting both would let the contract drift back without a test failing. - let result: Result = serde_json::from_value( - serde_json::json!({ "kind": "knownSource", "source_id": "ccm-logs" }), - ); + let result: Result = + serde_json::from_value(serde_json::json!({ "kind": "knownSource", "source_id": "ccm-logs" })); assert!(result.is_err(), "snake_case must not be accepted"); } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index d0bf0707f..430256b3f 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -266,10 +266,7 @@ mod tests { #[test] fn access_denied_without_a_path_serializes_a_null_path() { - let value = payload(AppError::access_denied( - SourceOperation::WorkspaceAction, - None, - )); + let value = payload(AppError::access_denied(SourceOperation::WorkspaceAction, None)); assert_eq!(value["kind"], "accessDenied"); assert!(value["path"].is_null()); @@ -351,7 +348,10 @@ mod tests { ] { let message = AppError::access_denied(operation, None).to_string(); for os in ["Windows", "macOS", "Linux"] { - assert!(!message.contains(os), "{operation:?} names {os}: {message}"); + assert!( + !message.contains(os), + "{operation:?} names {os}: {message}" + ); } } } diff --git a/src-tauri/src/jamf/connect.rs b/src-tauri/src/jamf/connect.rs index cd8b2ae1e..eb30e8cdb 100644 --- a/src-tauri/src/jamf/connect.rs +++ b/src-tauri/src/jamf/connect.rs @@ -31,7 +31,9 @@ fn user_regex() -> &'static Regex { fn idp_regex() -> &'static Regex { use std::sync::OnceLock; static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"provider=(?P\w+)").expect("static regex must compile")) + RE.get_or_init(|| { + Regex::new(r"provider=(?P\w+)").expect("static regex must compile") + }) } pub fn parse_connect_log_impl(path: &Path) -> Result, AppError> { diff --git a/src-tauri/src/jamf/detect.rs b/src-tauri/src/jamf/detect.rs index f996991d5..3246b86ad 100644 --- a/src-tauri/src/jamf/detect.rs +++ b/src-tauri/src/jamf/detect.rs @@ -206,11 +206,7 @@ fn read_jss_url() -> Option { return None; } let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if url.is_empty() { - None - } else { - Some(url) - } + if url.is_empty() { None } else { Some(url) } } fn read_jamf_connect_version() -> Option { @@ -225,11 +221,7 @@ fn read_jamf_connect_version() -> Option { return None; } let v = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if v.is_empty() { - None - } else { - Some(v) - } + if v.is_empty() { None } else { Some(v) } } fn read_jamf_connect_idp() -> Option { @@ -238,11 +230,14 @@ fn read_jamf_connect_idp() -> Option { continue; } let print_key = format!("Print :{key}"); - let output = - match output_with_timeout(PLISTBUDDY, &["-c", &print_key, plist], PLISTBUDDY_TIMEOUT) { - Some(o) => o, - None => continue, - }; + let output = match output_with_timeout( + PLISTBUDDY, + &["-c", &print_key, plist], + PLISTBUDDY_TIMEOUT, + ) { + Some(o) => o, + None => continue, + }; if !output.status.success() { continue; } @@ -269,10 +264,6 @@ fn build_summary( } else { "" }; - let log = if dirs.jamf_log { - "" - } else { - " · jamf.log missing" - }; + let log = if dirs.jamf_log { "" } else { " · jamf.log missing" }; format!("JAMF binary detected ({v}){connect}{log}") } diff --git a/src-tauri/src/jamf/mod.rs b/src-tauri/src/jamf/mod.rs index 89b40c7ae..b0c5bf102 100644 --- a/src-tauri/src/jamf/mod.rs +++ b/src-tauri/src/jamf/mod.rs @@ -1,9 +1,9 @@ -pub mod connect; -pub mod detect; pub mod models; pub mod paths; -pub mod policy_log; -pub mod profiles; -pub mod self_service; pub mod text; pub mod time; +pub mod detect; +pub mod policy_log; +pub mod self_service; +pub mod connect; +pub mod profiles; diff --git a/src-tauri/src/jamf/models.rs b/src-tauri/src/jamf/models.rs index 106017621..cd2fa351a 100644 --- a/src-tauri/src/jamf/models.rs +++ b/src-tauri/src/jamf/models.rs @@ -31,8 +31,8 @@ pub struct JamfDirectoryStatus { pub jamf_log: bool, pub jamf_app_support: bool, pub jamf_receipts: bool, - pub jamf_user_logs: bool, // ~/Library/Logs/JAMF - pub self_service_log: bool, // ~/Library/Logs/JAMF/selfservice.log + pub jamf_user_logs: bool, // ~/Library/Logs/JAMF + pub self_service_log: bool, // ~/Library/Logs/JAMF/selfservice.log pub connect_log: bool, pub connect_user_logs: bool, } diff --git a/src-tauri/src/jamf/paths.rs b/src-tauri/src/jamf/paths.rs index fb93b53f3..422bf1cd7 100644 --- a/src-tauri/src/jamf/paths.rs +++ b/src-tauri/src/jamf/paths.rs @@ -36,10 +36,7 @@ pub const JAMF_CONNECT_IDP_SOURCES: &[(&str, &str)] = &[ ), ("/Library/Preferences/com.jamf.connect.plist", "Provider"), // Legacy: the key this module originally queried. - ( - "/Library/Preferences/com.jamf.connect.plist", - "OIDCProvider", - ), + ("/Library/Preferences/com.jamf.connect.plist", "OIDCProvider"), ]; /// Returns the installed JAMF Connect app bundle, preferring the modern name. diff --git a/src-tauri/src/jamf/policy_log.rs b/src-tauri/src/jamf/policy_log.rs index 8d91d2949..61825d3a5 100644 --- a/src-tauri/src/jamf/policy_log.rs +++ b/src-tauri/src/jamf/policy_log.rs @@ -234,11 +234,7 @@ fn classify( return ( JamfPolicyTrigger::Other("install".to_string()), None, - Some( - pkg.trim_end_matches("...") - .trim_end_matches('.') - .to_string(), - ), + Some(pkg.trim_end_matches("...").trim_end_matches('.').to_string()), JamfPolicyResult::InProgress, ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe5f27f57..f0cb6797c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,9 +16,9 @@ pub mod intune; #[cfg(debug_assertions)] mod ipc_bridge; #[cfg(feature = "macos-diag")] -pub mod jamf; -#[cfg(feature = "macos-diag")] pub mod macos_diag; +#[cfg(feature = "macos-diag")] +pub mod jamf; mod menu; pub use cmtraceopen_parser::models; pub mod parser; diff --git a/src-tauri/tests/jamf_environment.rs b/src-tauri/tests/jamf_environment.rs index 6fb11c576..e66b50f8d 100644 --- a/src-tauri/tests/jamf_environment.rs +++ b/src-tauri/tests/jamf_environment.rs @@ -9,10 +9,7 @@ fn collect_environment_does_not_panic() { // An installed binary does not guarantee `jamf version` succeeds (it can // fail or time out), so only the inverse is a real invariant. if !env.jamf_installed { - assert!( - env.jamf_version.is_none(), - "version reported without a binary" - ); + assert!(env.jamf_version.is_none(), "version reported without a binary"); } } diff --git a/src-tauri/tests/jamf_ipc_contract.rs b/src-tauri/tests/jamf_ipc_contract.rs index 4a356e1f6..53d41267c 100644 --- a/src-tauri/tests/jamf_ipc_contract.rs +++ b/src-tauri/tests/jamf_ipc_contract.rs @@ -45,10 +45,7 @@ fn policy_trigger_data_variants_carry_a_value_field() { #[test] fn policy_result_matches_the_typescript_union() { assert_eq!(json(&JamfPolicyResult::Success), r#"{"type":"success"}"#); - assert_eq!( - json(&JamfPolicyResult::InProgress), - r#"{"type":"inProgress"}"# - ); + assert_eq!(json(&JamfPolicyResult::InProgress), r#"{"type":"inProgress"}"#); assert_eq!(json(&JamfPolicyResult::Unknown), r#"{"type":"unknown"}"#); assert_eq!( json(&JamfPolicyResult::Failure("Error running recon".into())), diff --git a/src-tauri/tests/jamf_known_sources.rs b/src-tauri/tests/jamf_known_sources.rs index 4c43778d0..e770c9979 100644 --- a/src-tauri/tests/jamf_known_sources.rs +++ b/src-tauri/tests/jamf_known_sources.rs @@ -22,10 +22,7 @@ fn jamf_known_sources_present() { let all = build_known_log_sources(); let jamf = jamf_sources(&all); let ids: Vec<&str> = jamf.iter().map(|s| s.id.as_str()).collect(); - assert!( - ids.contains(&"macos-jamf-log"), - "missing macos-jamf-log: {ids:?}" - ); + assert!(ids.contains(&"macos-jamf-log"), "missing macos-jamf-log: {ids:?}"); assert!(ids.contains(&"macos-jamf-app-support-logs")); assert!(ids.contains(&"macos-jamf-receipts")); assert!(ids.contains(&"macos-jamf-self-service-log")); @@ -43,11 +40,11 @@ fn jamf_log_default_file_is_jamf_log() { .iter() .find(|s| s.id == "macos-jamf-log") .expect("macos-jamf-log should exist"); - let intent = entry - .default_file_intent - .as_ref() - .expect("should have default file intent"); - assert!(intent.preferred_file_names.iter().any(|n| n == "jamf.log")); + let intent = entry.default_file_intent.as_ref().expect("should have default file intent"); + assert!(intent + .preferred_file_names + .iter() + .any(|n| n == "jamf.log")); } #[cfg(not(target_os = "macos"))] diff --git a/src-tauri/tests/jamf_parser_robustness.rs b/src-tauri/tests/jamf_parser_robustness.rs index 1bba452c9..7bed41c27 100644 --- a/src-tauri/tests/jamf_parser_robustness.rs +++ b/src-tauri/tests/jamf_parser_robustness.rs @@ -32,11 +32,7 @@ fn policy_log_survives_invalid_utf8_and_keeps_all_lines() { let result = parse_policy_log_impl(&path).expect("must not fail on invalid UTF-8"); assert_eq!(result.total_lines, 3); - assert_eq!( - result.events.len(), - 3, - "every line must still yield an event" - ); + assert_eq!(result.events.len(), 3, "every line must still yield an event"); assert_eq!(result.unparsed_lines, 0); let names: Vec<&str> = result @@ -46,11 +42,7 @@ fn policy_log_survives_invalid_utf8_and_keeps_all_lines() { .collect(); assert_eq!(names[0], "First"); assert_eq!(names[2], "Third"); - assert!( - names[1].starts_with("Ba"), - "decoded lossily: {:?}", - names[1] - ); + assert!(names[1].starts_with("Ba"), "decoded lossily: {:?}", names[1]); let _ = std::fs::remove_file(&path); } @@ -85,10 +77,7 @@ fn policy_log_tolerates_a_truncated_final_line() { let result = parse_policy_log_impl(&path).expect("parse"); assert_eq!(result.total_lines, 2); assert_eq!(result.events.len(), 1); - assert_eq!( - result.unparsed_lines, 1, - "the partial line is counted, not fatal" - ); + assert_eq!(result.unparsed_lines, 1, "the partial line is counted, not fatal"); let _ = std::fs::remove_file(&path); } @@ -201,11 +190,9 @@ fn an_over_long_line_does_not_disturb_later_offsets() { fn missing_files_are_not_errors_except_for_the_policy_log() { // Self Service / Connect may legitimately be absent; jamf.log going missing // is a condition worth surfacing. - assert!( - parse_self_service_log_impl(Path::new("/nonexistent/ss.log")) - .expect("absent Self Service log is empty, not an error") - .is_empty() - ); + assert!(parse_self_service_log_impl(Path::new("/nonexistent/ss.log")) + .expect("absent Self Service log is empty, not an error") + .is_empty()); assert!(parse_connect_log_impl(Path::new("/nonexistent/jc.log")) .expect("absent Connect log is empty, not an error") .is_empty()); diff --git a/src-tauri/tests/jamf_policy_log_parsing.rs b/src-tauri/tests/jamf_policy_log_parsing.rs index aac7fb35d..fb1c33d53 100644 --- a/src-tauri/tests/jamf_policy_log_parsing.rs +++ b/src-tauri/tests/jamf_policy_log_parsing.rs @@ -77,10 +77,7 @@ fn classifies_jss_connectivity_failure() { #[test] fn unparsed_lines_counted() { let result = parse_policy_log_impl(Path::new(BASIC)).expect("parse"); - assert_eq!( - result.total_lines, - result.events.len() + result.unparsed_lines - ); + assert_eq!(result.total_lines, result.events.len() + result.unparsed_lines); } #[test] @@ -132,10 +129,8 @@ Wed Jul 22 20:10:32 host jamf[6003]: Successfully installed Zscaler-osx-4.5.2.31 let installing = result .events .iter() - .find(|e| { - matches!(&e.result, JamfPolicyResult::InProgress) - && e.policy_name.as_deref() == Some("Zscaler-osx-4.5.2.312-installer.pkg") - }) + .find(|e| matches!(&e.result, JamfPolicyResult::InProgress) + && e.policy_name.as_deref() == Some("Zscaler-osx-4.5.2.312-installer.pkg")) .expect("the Installing line should name the package"); assert!(matches!(&installing.trigger, JamfPolicyTrigger::Other(k) if k == "install")); diff --git a/src-tauri/tests/jamf_real_fixtures.rs b/src-tauri/tests/jamf_real_fixtures.rs index efe9bd60d..575b90168 100644 --- a/src-tauri/tests/jamf_real_fixtures.rs +++ b/src-tauri/tests/jamf_real_fixtures.rs @@ -117,10 +117,7 @@ fn self_service_log_yields_user_actions() { let Some(dir) = fixture_dir() else { return }; let events = parse_self_service_log_impl(&dir.join("logs/selfservice.log")).expect("parse"); - assert!( - !events.is_empty(), - "real selfservice.log produced no events" - ); + assert!(!events.is_empty(), "real selfservice.log produced no events"); assert!( events.iter().any(|e| e.action == "triggerPolicy"), "capture is known to contain Self Service-initiated installs" @@ -173,8 +170,8 @@ fn captured_profiles_xml_parses_and_filters_to_jamf() { profiles.len() ); - let filtered = - app_lib::jamf::profiles::filter_jamf_profiles_impl(profiles, None).expect("filter"); + let filtered = app_lib::jamf::profiles::filter_jamf_profiles_impl(profiles, None) + .expect("filter"); assert!( !filtered.profiles.is_empty(), "payload-prefix matching found no JAMF profiles in a JAMF capture" diff --git a/src-tauri/tests/jamf_self_service_log_parsing.rs b/src-tauri/tests/jamf_self_service_log_parsing.rs index b33283c99..ada433488 100644 --- a/src-tauri/tests/jamf_self_service_log_parsing.rs +++ b/src-tauri/tests/jamf_self_service_log_parsing.rs @@ -28,10 +28,7 @@ fn parses_real_selfservice_log_shapes() { // API chatter is one action with the endpoint as the item. assert_eq!(events[4].action, "request"); - assert_eq!( - events[4].item_name.as_deref(), - Some("updateDevicePushToken") - ); + assert_eq!(events[4].item_name.as_deref(), Some("updateDevicePushToken")); assert_eq!(events[7].action, "warning"); assert!(events[7] @@ -40,10 +37,7 @@ fn parses_real_selfservice_log_shapes() { .is_some_and(|m| m.starts_with("A customized icon"))); // Binary requests are the user-side operations. - let triggers: Vec<&_> = events - .iter() - .filter(|e| e.action == "triggerPolicy") - .collect(); + let triggers: Vec<&_> = events.iter().filter(|e| e.action == "triggerPolicy").collect(); assert_eq!(triggers.len(), 2); assert_eq!(events.iter().filter(|e| e.action == "doRecon").count(), 1); }