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 58ad36f89..a244019d8 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 @@ -24,7 +24,7 @@ //! somebody else's transaction. use crate::intune::evidence::{ - IntuneAccessState, IntuneArtifactCoverage, IntuneArtifactStatus, IntuneErrorCode, + access_state_for_artifact_status, IntuneAccessState, IntuneArtifactCoverage, IntuneErrorCode, IntuneEvidenceRef, IntuneFindingConfidence, IntuneNamedValue, IntuneObservationContext, IntuneParseState, IntuneProvenance, IntuneSensitivity, IntuneTimestamp, IntuneTimestampKind, }; @@ -92,18 +92,6 @@ pub fn analyze_store_bundle(artifacts: &[StoreSourceArtifact]) -> StoreAnalysis // ── Observation extraction ────────────────────────────────────────────────── -fn access_state_for(status: &IntuneArtifactStatus) -> IntuneAccessState { - match status { - IntuneArtifactStatus::Available => IntuneAccessState::Available, - IntuneArtifactStatus::Missing => IntuneAccessState::Missing, - IntuneArtifactStatus::PermissionDenied => IntuneAccessState::PermissionDenied, - IntuneArtifactStatus::Capped => IntuneAccessState::Capped, - IntuneArtifactStatus::Skipped => IntuneAccessState::Skipped, - IntuneArtifactStatus::ParseFailed => IntuneAccessState::Failed, - IntuneArtifactStatus::Unsupported => IntuneAccessState::Unsupported, - } -} - fn collect_from_artifact(artifact: &StoreSourceArtifact, observations: &mut Vec) { let Some(payload) = &artifact.payload else { return; @@ -189,7 +177,7 @@ fn ime_context( // IME records quote package paths and app display names. sensitivity: IntuneSensitivity::Sensitive, parse_state, - access_state: access_state_for(&artifact.status), + access_state: access_state_for_artifact_status(&artifact.status), } } @@ -1046,7 +1034,7 @@ fn build_coverage( #[cfg(test)] mod tests { use super::*; - use crate::intune::evidence::IntuneSourceKind; + use crate::intune::evidence::{IntuneArtifactStatus, IntuneSourceKind}; use crate::intune::normalized::{NormalizedEventLevel, NormalizedWindowsEvent}; fn context(artifact: &str, record: u64, kind: IntuneSourceKind) -> IntuneObservationContext { diff --git a/crates/cmtraceopen-parser/src/intune/apps/windows/win32/sources.rs b/crates/cmtraceopen-parser/src/intune/apps/windows/win32/sources.rs index 34d33a183..66df46a46 100644 --- a/crates/cmtraceopen-parser/src/intune/apps/windows/win32/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/apps/windows/win32/sources.rs @@ -12,7 +12,7 @@ //! would silently classify as an unknown artifact. use crate::intune::evidence::{ - IntuneAccessState, IntuneArtifactCoverage, IntuneArtifactStatus, IntuneSourceKind, + artifact_status_for_access_state, IntuneAccessState, IntuneArtifactCoverage, IntuneSourceKind, }; use super::models::Win32SourceKind; @@ -224,22 +224,6 @@ pub fn classify_artifact( } } -/// Map a caller-declared access state onto the shared coverage status. -/// -/// Binding these two vocabularies in one place is what stops a bundle declaring -/// an artifact absent and then reporting coverage for it as available. -pub(super) fn coverage_status(access_state: &IntuneAccessState) -> IntuneArtifactStatus { - match access_state { - IntuneAccessState::Available => IntuneArtifactStatus::Available, - IntuneAccessState::Missing => IntuneArtifactStatus::Missing, - IntuneAccessState::PermissionDenied => IntuneArtifactStatus::PermissionDenied, - IntuneAccessState::Capped => IntuneArtifactStatus::Capped, - IntuneAccessState::Skipped => IntuneArtifactStatus::Skipped, - IntuneAccessState::Failed => IntuneArtifactStatus::ParseFailed, - IntuneAccessState::Unsupported => IntuneArtifactStatus::Unsupported, - } -} - /// The shared source kind that matches a Win32 artifact kind. pub(super) fn intune_source_kind(kind: Win32SourceKind) -> IntuneSourceKind { match kind { @@ -273,7 +257,7 @@ pub(super) fn artifact_coverage( IntuneArtifactCoverage { artifact_id: input.artifact_id.clone(), family: coverage_family(kind).to_owned(), - status: coverage_status(&input.access_state), + status: artifact_status_for_access_state(&input.access_state), detail, observed_at_utc: input.captured_at_utc.clone().unwrap_or_default(), evidence: Vec::new(), @@ -403,27 +387,7 @@ mod tests { assert_eq!(split_rotation("aé.lo"), ("aé.lo".to_string(), Some(0))); } - #[test] - fn every_access_state_maps_to_exactly_one_coverage_status() { - for (state, status) in [ - ( - IntuneAccessState::Available, - IntuneArtifactStatus::Available, - ), - (IntuneAccessState::Missing, IntuneArtifactStatus::Missing), - ( - IntuneAccessState::PermissionDenied, - IntuneArtifactStatus::PermissionDenied, - ), - (IntuneAccessState::Capped, IntuneArtifactStatus::Capped), - (IntuneAccessState::Skipped, IntuneArtifactStatus::Skipped), - (IntuneAccessState::Failed, IntuneArtifactStatus::ParseFailed), - ( - IntuneAccessState::Unsupported, - IntuneArtifactStatus::Unsupported, - ), - ] { - assert_eq!(coverage_status(&state), status); - } - } + // `every_access_state_maps_to_exactly_one_coverage_status` moved to + // `crate::intune::evidence` with the mapping it asserts, where it now also + // pins the inverse direction the Microsoft Store lane depends on. } diff --git a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs index 20f20ad20..085af91a2 100644 --- a/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs +++ b/crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/sources.rs @@ -21,8 +21,8 @@ use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::intune::evidence::{ - intune_raw_preserving_string_enum, IntuneAccessState, IntuneArtifactStatus, IntuneErrorCode, - IntuneNamedValue, IntuneObservationContext, + artifact_status_for_access_state, intune_raw_preserving_string_enum, IntuneAccessState, + IntuneArtifactStatus, IntuneErrorCode, IntuneNamedValue, IntuneObservationContext, }; use crate::intune::normalized::NormalizedWindowsEvent; @@ -72,16 +72,13 @@ pub enum AutopilotCaptureState { impl AutopilotCaptureState { /// The coverage status this capture state implies before the reducer has /// looked at any bytes. + /// + /// Derived from [`Self::access_state`] through the shared access-state -> + /// status mapping rather than restated as its own table: this lane only gets + /// to choose how a *collector* vocabulary lands on `IntuneAccessState`. How + /// that then lands on `IntuneArtifactStatus` is not Autopilot's to decide. 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, - } + artifact_status_for_access_state(&self.access_state()) } /// The observation-level access state records from this artifact carry. diff --git a/crates/cmtraceopen-parser/src/intune/evidence.rs b/crates/cmtraceopen-parser/src/intune/evidence.rs index bc2a47b51..2901c15ef 100644 --- a/crates/cmtraceopen-parser/src/intune/evidence.rs +++ b/crates/cmtraceopen-parser/src/intune/evidence.rs @@ -253,6 +253,45 @@ pub enum IntuneArtifactStatus { Unsupported, } +/// The coverage status a declared access state implies. +/// +/// [`IntuneAccessState`] and [`IntuneArtifactStatus`] are the same seven facts +/// told from two vantage points: the observation envelope says how readable a +/// source was, coverage says what became of the artifact. Binding the two +/// vocabularies in one place is what stops a bundle declaring an artifact absent +/// and then reporting coverage for it as available. +/// +/// The `match` is exhaustive by design. A new variant on either enum must be a +/// compile error here, not a silently defaulted arm in whichever workload +/// happened to copy the table last. +pub fn artifact_status_for_access_state(access_state: &IntuneAccessState) -> IntuneArtifactStatus { + match access_state { + IntuneAccessState::Available => IntuneArtifactStatus::Available, + IntuneAccessState::Missing => IntuneArtifactStatus::Missing, + IntuneAccessState::PermissionDenied => IntuneArtifactStatus::PermissionDenied, + IntuneAccessState::Capped => IntuneArtifactStatus::Capped, + IntuneAccessState::Skipped => IntuneArtifactStatus::Skipped, + IntuneAccessState::Failed => IntuneArtifactStatus::ParseFailed, + IntuneAccessState::Unsupported => IntuneArtifactStatus::Unsupported, + } +} + +/// The access state a coverage status implies. +/// +/// The exact inverse of [`artifact_status_for_access_state`]; see that function +/// for why the pair lives here rather than in each workload. +pub fn access_state_for_artifact_status(status: &IntuneArtifactStatus) -> IntuneAccessState { + match status { + IntuneArtifactStatus::Available => IntuneAccessState::Available, + IntuneArtifactStatus::Missing => IntuneAccessState::Missing, + IntuneArtifactStatus::PermissionDenied => IntuneAccessState::PermissionDenied, + IntuneArtifactStatus::Capped => IntuneAccessState::Capped, + IntuneArtifactStatus::Skipped => IntuneAccessState::Skipped, + IntuneArtifactStatus::ParseFailed => IntuneAccessState::Failed, + IntuneArtifactStatus::Unsupported => IntuneAccessState::Unsupported, + } +} + /// One expected artifact and what became of it. /// /// Coverage is emitted for artifacts that were *not* read as well as those that @@ -363,6 +402,35 @@ mod tests { } } + /// Ported from `intune::apps::windows::win32::sources`, where this table was + /// asserted against one of three byte-identical copies of the mapping. + #[test] + fn every_access_state_maps_to_exactly_one_coverage_status() { + for (state, status) in [ + ( + IntuneAccessState::Available, + IntuneArtifactStatus::Available, + ), + (IntuneAccessState::Missing, IntuneArtifactStatus::Missing), + ( + IntuneAccessState::PermissionDenied, + IntuneArtifactStatus::PermissionDenied, + ), + (IntuneAccessState::Capped, IntuneArtifactStatus::Capped), + (IntuneAccessState::Skipped, IntuneArtifactStatus::Skipped), + (IntuneAccessState::Failed, IntuneArtifactStatus::ParseFailed), + ( + IntuneAccessState::Unsupported, + IntuneArtifactStatus::Unsupported, + ), + ] { + assert_eq!(artifact_status_for_access_state(&state), status); + // The pair is a bijection, so the same table read backwards is the + // whole contract of the inverse. + assert_eq!(access_state_for_artifact_status(&status), state); + } + } + #[test] fn finding_without_evidence_or_coverage_gap_is_not_evidence_backed() { let mut finding = IntuneFinding { diff --git a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs index c97198b50..170eabff1 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs @@ -30,7 +30,9 @@ use cmtraceopen_parser::intune::enrollment::windows::autopilot::{ AutopilotPhase, AutopilotSnapshot, AutopilotSourceInput, }; use serde_json::{json, Value}; -use support::{corpus_root, load_json, mutated, scenario_names, validate_scenario, Failures}; +use support::{ + corpus_root, load_json, mutated, scenario_names, validate_scenario, wire, Failures, +}; /// The corpus this leaf owns. const CORPUS: &str = "enrollment/windows/autopilot"; @@ -56,9 +58,17 @@ const SCENARIOS: [&str; 15] = [ ]; fn scenario_root(scenario: &str) -> PathBuf { - corpus_root(CORPUS).join(scenario) + support::scenario_root(CORPUS, scenario) } +/// The manifest's capture state as this lane's own collector vocabulary. +/// +/// Deliberately not `support::artifact_status_for_capture_state`: Autopilot's +/// input is `AutopilotCaptureState`, not `IntuneArtifactStatus`, and the mapping +/// is serde's own `rename_all = "camelCase"` rather than a hand-written table. +/// Routing it through the shared helper would mean re-deriving a lane enum from +/// a status, which is backwards, and would drop serde as the single definition of +/// how that enum spells itself on the wire. 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}")) @@ -119,10 +129,6 @@ fn reduce(scenario: &str) -> (AutopilotSnapshot, Value) { (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] diff --git a/crates/cmtraceopen-parser/tests/intune_windows_compliance.rs b/crates/cmtraceopen-parser/tests/intune_windows_compliance.rs index 178dc5be5..7338f33ad 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_compliance.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_compliance.rs @@ -20,9 +20,11 @@ use cmtraceopen_parser::intune::device::windows::compliance::{ analyze_compliance_bundle, redacted_export_projection, ComplianceSnapshot, ComplianceSourceInput, }; -use cmtraceopen_parser::intune::evidence::IntuneArtifactStatus; use serde_json::{json, Value}; -use support::{corpus_root, load_json, mutated, scenario_names, validate_scenario, Failures}; +use support::{ + artifact_status_for_capture_state, corpus_root, load_json, mutated, scenario_names, + sorted_evidence_ids, validate_scenario, Failures, +}; const CORPUS: &str = "device/windows/compliance"; @@ -64,20 +66,7 @@ const LOCAL_CLAIM_FINDINGS: [&str; 5] = [ // ── Loading ───────────────────────────────────────────────────────────────── fn scenario_root(scenario: &str) -> PathBuf { - corpus_root(CORPUS).join(scenario) -} - -fn capture_status(capture_state: &str) -> IntuneArtifactStatus { - match capture_state { - "captured" => IntuneArtifactStatus::Available, - "capped" => IntuneArtifactStatus::Capped, - "absent" => IntuneArtifactStatus::Missing, - "accessDenied" => IntuneArtifactStatus::PermissionDenied, - "skipped" => IntuneArtifactStatus::Skipped, - "unsupported" => IntuneArtifactStatus::Unsupported, - "parseFailed" => IntuneArtifactStatus::ParseFailed, - other => panic!("unknown captureState {other:?}"), - } + support::scenario_root(CORPUS, scenario) } /// Build the analyzer input exactly as a native collector would report it. @@ -97,7 +86,9 @@ fn sources(root: &Path, manifest: &Value) -> Vec { .expect("artifactId") .to_string(), family: artifact["family"].as_str().expect("family").to_string(), - status: capture_status(artifact["captureState"].as_str().expect("captureState")), + status: artifact_status_for_capture_state( + artifact["captureState"].as_str().expect("captureState"), + ), captured_at_utc: artifact["capturedUtc"] .as_str() .expect("capturedUtc") @@ -147,20 +138,6 @@ fn families(manifest: &Value) -> BTreeMap { .collect() } -fn evidence_ids(value: &Value) -> Vec { - value - .as_array() - .map(|items| { - let mut ids = items - .iter() - .map(|item| item["evidenceId"].as_str().unwrap_or_default().to_string()) - .collect::>(); - ids.sort(); - ids - }) - .unwrap_or_default() -} - fn artifact_ids(value: &Value) -> Vec { value .as_array() @@ -277,7 +254,7 @@ fn assert_settings(scenario: &str, actual: &Value, expected: &Value) { ); } assert_eq!( - evidence_ids(&got["evidence"]), + sorted_evidence_ids(&got["evidence"]), strings(&want["evidence"]), "{scenario}: settings[{index}].evidence" ); @@ -302,7 +279,7 @@ fn assert_access(scenario: &str, actual: &Value, expected: &Value) { ); } assert_eq!( - evidence_ids(&got["matchedEvidence"]), + sorted_evidence_ids(&got["matchedEvidence"]), strings(&want["matchedEvidence"]), "{scenario}: access[{index}].matchedEvidence" ); @@ -347,7 +324,7 @@ fn assert_findings(scenario: &str, actual: &Value, expected: &Value) { ); } assert_eq!( - evidence_ids(&got["evidence"]), + sorted_evidence_ids(&got["evidence"]), strings(&want["evidence"]), "{scenario}: findings[{index}].evidence" ); diff --git a/crates/cmtraceopen-parser/tests/intune_windows_configuration.rs b/crates/cmtraceopen-parser/tests/intune_windows_configuration.rs index 869924eb8..eb05c242e 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_configuration.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_configuration.rs @@ -23,7 +23,7 @@ use cmtraceopen_parser::intune::device::windows::configuration::{ ConfigurationSetting, ConfigurationSnapshot, INTUNE_CONFIGURATION_SCHEMA_VERSION, }; use cmtraceopen_parser::intune::evidence::{ - IntuneArtifactCoverage, IntuneArtifactStatus, IntuneEvidenceRef, IntuneNamedValue, + IntuneArtifactCoverage, IntuneEvidenceRef, IntuneNamedValue, IntuneObservationContext, IntuneParseState, IntuneProvenance, IntuneSensitivity, IntuneSourceKind, IntuneTimestamp, IntuneTimestampKind, }; @@ -31,7 +31,10 @@ use cmtraceopen_parser::intune::normalized::{ NormalizedEventLevel, NormalizedSettingOutcome, NormalizedSettingReport, NormalizedWindowsEvent, INTUNE_NORMALIZED_SCHEMA_VERSION, }; -use support::{load_json, mutated, scenario_names, validate_scenario, Failures}; +use support::{ + artifact_status_for_capture_state, load_json, mutated, scenario_names, validate_scenario, wire, + Failures, +}; const CORPUS: &str = "device/windows/configuration"; @@ -91,7 +94,7 @@ fn corpus_root() -> PathBuf { } fn scenario_root(scenario: &str) -> PathBuf { - corpus_root().join(scenario) + support::scenario_root(CORPUS, scenario) } // ── Loading ───────────────────────────────────────────────────────────────── @@ -166,7 +169,7 @@ fn coverage_entry(artifact: &Value) -> IntuneArtifactCoverage { .expect("artifact declares an id") .to_owned(), family: artifact["family"].as_str().unwrap_or_default().to_owned(), - status: status_for(capture_state), + status: artifact_status_for_capture_state(capture_state), detail: None, observed_at_utc: artifact["capturedUtc"] .as_str() @@ -176,27 +179,6 @@ fn coverage_entry(artifact: &Value) -> IntuneArtifactCoverage { } } -/// The manifest's capture state, mapped onto the shared coverage vocabulary. -/// -/// Kept explicit rather than derived from the harness table so a drift in either -/// direction is a compile-visible change here. -fn status_for(capture_state: &str) -> IntuneArtifactStatus { - match capture_state { - "captured" => IntuneArtifactStatus::Available, - "capped" => IntuneArtifactStatus::Capped, - "absent" => IntuneArtifactStatus::Missing, - "accessDenied" => IntuneArtifactStatus::PermissionDenied, - "skipped" => IntuneArtifactStatus::Skipped, - "unsupported" => IntuneArtifactStatus::Unsupported, - "parseFailed" => IntuneArtifactStatus::ParseFailed, - other => panic!("unknown captureState {other:?}"), - } -} - -fn wire(value: &T) -> Value { - serde_json::to_value(value).expect("shared contract types serialize") -} - /// The semantic projection compared against `expected.json`. fn project(setting: &ConfigurationSetting) -> Value { let source_ids = setting diff --git a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs index 2f9408735..bfe09369b 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs @@ -20,9 +20,12 @@ use cmtraceopen_parser::intune::apps::windows::microsoft_store::{ analyze_store_bundle, redacted_export_projection, StoreAnalysis, StoreArtifactPayload, StoreInstallerFamily, StoreSourceArtifact, }; -use cmtraceopen_parser::intune::evidence::{IntuneArtifactStatus, IntuneSourceKind}; +use cmtraceopen_parser::intune::evidence::IntuneSourceKind; use serde_json::{json, Value}; -use support::{corpus_root, load_json, mutated, scenario_names, validate_scenario, Failures}; +use support::{ + artifact_status_for_capture_state, corpus_root, load_json, mutated, scenario_names, + validate_scenario, Failures, +}; /// The scenario matrix this leaf commits to. /// @@ -51,24 +54,7 @@ const SCENARIOS: [&str; 17] = [ const CORPUS: &str = "apps/windows/microsoft-store"; fn scenario_root(scenario: &str) -> PathBuf { - corpus_root(CORPUS).join(scenario) -} - -/// Map a manifest capture state onto the artifact status the analyzer consumes. -/// -/// This is the whole job of the native collector that will eventually feed the -/// pure crate, so doing it here keeps the fixture honest about the boundary. -fn status_for(capture_state: &str) -> IntuneArtifactStatus { - match capture_state { - "captured" => IntuneArtifactStatus::Available, - "capped" => IntuneArtifactStatus::Capped, - "absent" => IntuneArtifactStatus::Missing, - "accessDenied" => IntuneArtifactStatus::PermissionDenied, - "skipped" => IntuneArtifactStatus::Skipped, - "unsupported" => IntuneArtifactStatus::Unsupported, - "parseFailed" => IntuneArtifactStatus::ParseFailed, - other => panic!("unknown captureState {other:?}"), - } + support::scenario_root(CORPUS, scenario) } fn payload_for(path: &Path) -> StoreArtifactPayload { @@ -104,7 +90,9 @@ fn load_bundle(scenario: &str, manifest: &Value) -> Vec { artifact["sourceKind"].clone(), ) .expect("sourceKind"), - status: status_for(artifact["captureState"].as_str().expect("captureState")), + status: artifact_status_for_capture_state( + artifact["captureState"].as_str().expect("captureState"), + ), detail: artifact["detail"].as_str().map(str::to_owned), observed_at_utc: artifact["capturedUtc"] .as_str() @@ -128,6 +116,13 @@ fn load_scenario(scenario: &str) -> (StoreAnalysis, Value) { ) } +/// The evidence ids cited by an array, **in the order the reducer emitted them**. +/// +/// Deliberately not `support::sorted_evidence_ids`. This leaf asserts citation +/// order: a transaction's `evidence` is positionally in step with its +/// `observations` (see `assert_transactions`), so sorting here would silently +/// retire that pairing. It also panics rather than defaulting, because a missing +/// `evidenceId` in this corpus is a corpus bug, not an absent citation. fn evidence_ids(value: &Value) -> Vec { value .as_array() diff --git a/crates/cmtraceopen-parser/tests/intune_windows_win32.rs b/crates/cmtraceopen-parser/tests/intune_windows_win32.rs index 79fd29826..367f875c7 100644 --- a/crates/cmtraceopen-parser/tests/intune_windows_win32.rs +++ b/crates/cmtraceopen-parser/tests/intune_windows_win32.rs @@ -20,10 +20,13 @@ use cmtraceopen_parser::intune::apps::windows::win32::{ analyze_win32_bundle, derive_findings, redacted_export_projection, Win32Analysis, Win32SourceInput, }; -use cmtraceopen_parser::intune::evidence::IntuneAccessState; use cmtraceopen_parser::intune::ime_parser::parse_ime_content; use serde_json::{json, Value}; -use support::{load_json, mutated, scenario_names, validate_scenario, Failures}; +use support::{ + access_state_for_capture_state, load_json, mutated, scenario_names, validate_scenario, Failures, +}; + +const CORPUS: &str = "apps/windows/win32"; /// The scenario matrix issue #357 requires, pinned so that adding or removing a /// scenario is a deliberate, compile-visible change rather than a silent drift. @@ -51,25 +54,11 @@ const SCENARIOS: [&str; 20] = [ ]; fn corpus() -> PathBuf { - support::corpus_root("apps/windows/win32") + support::corpus_root(CORPUS) } -/// The access state a manifest capture state declares. -/// -/// Kept here rather than in the crate because it translates the *fixture* -/// vocabulary into the library's, and the library must never need to know how a -/// fixture spells things. -fn access_state(capture_state: &str) -> IntuneAccessState { - match capture_state { - "captured" => IntuneAccessState::Available, - "capped" => IntuneAccessState::Capped, - "absent" => IntuneAccessState::Missing, - "accessDenied" => IntuneAccessState::PermissionDenied, - "skipped" => IntuneAccessState::Skipped, - "unsupported" => IntuneAccessState::Unsupported, - "parseFailed" => IntuneAccessState::Failed, - other => panic!("unknown captureState {other:?}"), - } +fn scenario_root(scenario: &str) -> PathBuf { + support::scenario_root(CORPUS, scenario) } fn inputs_for(scenario_root: &Path, manifest: &Value) -> Vec { @@ -95,7 +84,7 @@ fn inputs_for(scenario_root: &Path, manifest: &Value) -> Vec { .expect("originalBasename") .to_owned(), file_path: artifact["sanitizedSourcePath"].as_str().map(str::to_owned), - access_state: access_state( + access_state: access_state_for_capture_state( artifact["captureState"].as_str().expect("captureState"), ), content, @@ -106,7 +95,7 @@ fn inputs_for(scenario_root: &Path, manifest: &Value) -> Vec { } fn load(scenario: &str) -> (Win32Analysis, Value, Value, PathBuf) { - let root = corpus().join(scenario); + let root = scenario_root(scenario); let manifest = load_json(&root.join("manifest.json")); let expected = load_json(&root.join("expected.json")); let analysis = analyze_win32_bundle(&inputs_for(&root, &manifest)); @@ -1066,7 +1055,7 @@ fn a_bare_prefix_probe_cannot_hide_an_undeclared_profile_path() { /// would be sensitive-shaped fixture material nothing proves redacted. #[test] fn a_privacy_probe_without_a_redaction_assertion_is_rejected() { - let root = corpus().join("privacy-redaction"); + let root = scenario_root("privacy-redaction"); let manifest = load_json(&root.join("manifest.json")); let expected = load_json(&root.join("expected.json")); let corrupted = mutated( @@ -1090,7 +1079,7 @@ fn a_privacy_probe_without_a_redaction_assertion_is_rejected() { /// partially proven redacted. #[test] fn a_probe_covered_only_by_a_substring_needle_is_rejected() { - let root = corpus().join("privacy-redaction"); + let root = scenario_root("privacy-redaction"); let manifest = load_json(&root.join("manifest.json")); let expected = load_json(&root.join("expected.json")); // Replace the exact-path needle with an unrelated one; the path probe is @@ -1127,7 +1116,7 @@ fn a_probe_covered_only_by_a_substring_needle_is_rejected() { /// the fixture with nothing proving it redacted. #[test] fn a_non_array_privacy_probes_declaration_is_rejected() { - let root = corpus().join("privacy-redaction"); + let root = scenario_root("privacy-redaction"); let manifest = load_json(&root.join("manifest.json")); let expected = load_json(&root.join("expected.json")); let corrupted = mutated( @@ -1152,7 +1141,7 @@ fn a_non_array_privacy_probes_declaration_is_rejected() { /// which the old whole-descriptor substring strip silently hid. #[test] fn a_probe_leaking_into_a_descriptor_output_field_is_detected() { - let root = corpus().join("privacy-redaction"); + let root = scenario_root("privacy-redaction"); let manifest = load_json(&root.join("manifest.json")); let expected = load_json(&root.join("expected.json")); let corrupted = mutated( @@ -1201,7 +1190,7 @@ fn a_probe_leaking_into_a_descriptor_output_field_is_detected() { /// smuggled in as an unasserted needle must stay detectable. #[test] fn a_non_probe_needle_carrying_forbidden_material_is_rejected() { - let root = corpus().join("privacy-redaction"); + let root = scenario_root("privacy-redaction"); let manifest = load_json(&root.join("manifest.json")); let expected = load_json(&root.join("expected.json")); let mut corrupted = expected.clone(); @@ -1245,7 +1234,7 @@ fn a_non_probe_needle_carrying_forbidden_material_is_rejected() { #[test] fn a_coverage_status_contradicting_the_capture_state_is_rejected() { - let root = corpus().join("incomplete-bundle-missing-appworkload"); + let root = scenario_root("incomplete-bundle-missing-appworkload"); let manifest = load_json(&root.join("manifest.json")); let expected = load_json(&root.join("expected.json")); let corrupted = mutated(&expected, "/coverage/1/status", json!("available")); diff --git a/crates/cmtraceopen-parser/tests/support/mod.rs b/crates/cmtraceopen-parser/tests/support/mod.rs index babb5214d..72d830424 100644 --- a/crates/cmtraceopen-parser/tests/support/mod.rs +++ b/crates/cmtraceopen-parser/tests/support/mod.rs @@ -23,6 +23,9 @@ //! `src-tauri/tests/common/mod.rs` sets the same precedent. #![allow(dead_code)] +use cmtraceopen_parser::intune::evidence::{ + access_state_for_artifact_status, IntuneAccessState, IntuneArtifactStatus, +}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path, PathBuf}; @@ -95,6 +98,73 @@ pub fn corpus_root(corpus_relative: &str) -> PathBuf { root } +/// One scenario directory, e.g. `scenario_root("apps/windows/win32", "install-success")`. +pub fn scenario_root(corpus_relative: &str, scenario: &str) -> PathBuf { + corpus_root(corpus_relative).join(scenario) +} + +// ── Fixture vocabulary -> library vocabulary ──────────────────────────────── + +/// Map a manifest `captureState` onto the artifact status the analyzers consume. +/// +/// This is the job of the native collector that will eventually feed the pure +/// crate, so the fixture harness performs it: the library must never need to +/// know how a fixture spells things. It lives here, once, because a workload +/// that spelled this table differently from its neighbours would be declaring a +/// different collector boundary without saying so. +/// +/// The `match` is over strings and so cannot be exhaustive; the panic arm is the +/// substitute, and [`CAPTURE_STATES`] is the list it accepts. +pub fn artifact_status_for_capture_state(capture_state: &str) -> IntuneArtifactStatus { + match capture_state { + "captured" => IntuneArtifactStatus::Available, + "capped" => IntuneArtifactStatus::Capped, + "absent" => IntuneArtifactStatus::Missing, + "accessDenied" => IntuneArtifactStatus::PermissionDenied, + "skipped" => IntuneArtifactStatus::Skipped, + "unsupported" => IntuneArtifactStatus::Unsupported, + "parseFailed" => IntuneArtifactStatus::ParseFailed, + other => panic!("unknown captureState {other:?}"), + } +} + +/// The same table expressed as the observation-level access state. +/// +/// Some inputs (Win32) are built from `IntuneAccessState` rather than +/// `IntuneArtifactStatus`. Rather than a second hand-written table, this is the +/// status table composed with the library's own status -> access mapping, so a +/// fixture can never disagree with the crate about what `parseFailed` means. +pub fn access_state_for_capture_state(capture_state: &str) -> IntuneAccessState { + access_state_for_artifact_status(&artifact_status_for_capture_state(capture_state)) +} + +// ── Wire projections ──────────────────────────────────────────────────────── + +/// Serialize a shared contract type for comparison against `expected.json`. +pub fn wire(value: &T) -> Value { + serde_json::to_value(value).expect("shared contract types serialize") +} + +/// The evidence ids cited by an array, sorted. +/// +/// For consumers whose contract is the *set* of citations. Sorting both sides of +/// the assertion is what makes that explicit. A consumer whose contract includes +/// the citation *order* must not use this; see +/// `docs/architecture/shared-vs-workload-invariants.md`. +pub fn sorted_evidence_ids(value: &Value) -> Vec { + let mut ids = value + .as_array() + .map(|items| { + items + .iter() + .map(|item| item["evidenceId"].as_str().unwrap_or_default().to_string()) + .collect::>() + }) + .unwrap_or_default(); + ids.sort(); + ids +} + /// Sorted scenario directory names inside a corpus. /// /// Panics when the corpus directory is missing. Returning an empty vec made a diff --git a/docs/architecture/decisions/ADR-001-evidence-strength-confidence.md b/docs/architecture/decisions/ADR-001-evidence-strength-confidence.md index 65890a1c3..2e1d9fe1d 100644 --- a/docs/architecture/decisions/ADR-001-evidence-strength-confidence.md +++ b/docs/architecture/decisions/ADR-001-evidence-strength-confidence.md @@ -5,3 +5,46 @@ - **Decision:** Keep `IntuneFindingConfidence` as the public conclusion projection. The five evidence-strength levels — `Authoritative`, `Strong`, `Corroborating`, `Weak`, and `Untrusted` — are a conceptual vocabulary for reasoning and review, not a requirement for a shared enum, envelope, or runtime representation. Workload contracts may express the distinctions in workload-appropriate types or tests. Evidence strength may constrain confidence, but confidence must never be used as a substitute for source authority. Non-assessable evidence cannot produce a terminal conclusion. - **Consequences:** Workload reducers retain domain-specific confidence rules. Framework v1 adds no numeric score, universal reducer, or mandatory shared evidence-strength type. A later shared helper is justified only by a concrete Store test and must preserve this separation. - **Executable invariants:** Weak evidence cannot become authoritative through duplication; untrusted or non-assessable evidence cannot produce high-confidence terminal success/failure; coverage gaps cannot raise confidence. + +## Addendum: the directional doctrine is shared prose, not shared code + +Added during the Framework v1 thin extraction, after an inspection found the same +sentence implemented two structurally different ways. + +**The doctrine, stated once:** non-assessable evidence cannot *prove* a +conclusion, but a *recorded* non-assessable failure must still *block* a success. +"Absence proves nothing" is not "presence proves nothing". Any record strong +enough to fail an operation when readable is strong enough to block that +operation's success when unreadable. + +**The mechanism is deliberately per-workload, because "recorded failure" is a +workload fact, not a framework one.** The two lanes that implement it do not +gate on the same thing and must not be merged: + +| Lane | What is unreadable | What it gates on | +|---|---|---| +| Autopilot | the artifact or section envelope, i.e. `!(access_state == Available && parse_state == Parsed)` | the envelope **plus** a failure-shaped signal: `!is_assessable(observation)` AND `observation.signal.is_terminal_failure()` | +| Configuration | nothing about the envelope; the *result token inside a readable record* | a readable, device-side `CommandFailure` event whose disposition never reached terminal. "The direction is evidence; the code is not." | + +Sites: + +- Autopilot: `crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs` + - `recorded_non_assessable_failure_sections` and + `recorded_non_assessable_failure_observations` (~650-707) define the predicate. + - Enforced at ~1668-1688, where the pair short-circuits an otherwise clean + `profile.applied && handoff.esp_observed` into `InsufficientEvidence`. + - The envelope predicate itself is + `crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs` + ~248-256 (`is_assessable` = `Available` + `Parsed`). +- Configuration: `crates/cmtraceopen-parser/src/intune/device/windows/configuration/sources.rs` + ~163-174 (`is_unassessable_failure`), enforced in + `crates/cmtraceopen-parser/src/intune/device/windows/configuration/reducer.rs` ~246-263, which turns `Applied`/`Removed` into + `Contested` rather than into an outcome-free state. + +**Consequence for future work:** a PR that "unifies the ADR-001 gate" across +these two lanes is unifying an English sentence with a code path. Autopilot's +predicate reads the envelope; Configuration's reads a payload token in a record +whose envelope is fine. Neither predicate can express the other's case. Extract +the prose (this addendum) and leave the mechanisms alone. The catalogue of every +other divergence in this family, and why each is protected, is +`docs/architecture/shared-vs-workload-invariants.md`. diff --git a/docs/architecture/shared-vs-workload-invariants.md b/docs/architecture/shared-vs-workload-invariants.md new file mode 100644 index 000000000..715730933 --- /dev/null +++ b/docs/architecture/shared-vs-workload-invariants.md @@ -0,0 +1,183 @@ +# Shared vs workload-specific invariants (Intune Windows reducers) + +## Why this file exists + +Framework v1 asked what the Intune Windows workload reducers have in common. The +answer, after inspection, was: much less than it looks. Five lanes (Win32, +Microsoft Store, Autopilot, Configuration, plus Compliance) use the same +vocabulary and the same doc-comment idioms, so their rules *read* alike while +deciding different things about different evidence. + +This is a catalogue of what is genuinely shared and what is deliberately not. +Everything in the "protected divergences" section is **tested** and +**intentional**. Each entry exists because a reasonable person reading two lanes +side by side would conclude one of them is wrong, and would be mistaken. + +**If you are here because you are writing a consistency PR: the entries below are +the answer to "why is this inconsistent?". Do not delete them to make the lanes +match. If you believe one is genuinely wrong, that is a RED-first behavior-change +PR with its own issue, not a refactor.** + +## Actually shared + +| Concern | Owner | Notes | +|---|---|---| +| Evidence envelope types (`IntuneObservationContext`, `IntuneEvidenceRef`, `IntuneProvenance`, `IntuneArtifactCoverage`, `IntuneFinding`, ...) | `crates/cmtraceopen-parser/src/intune/evidence.rs` | Two separate compatibility questions, and only one of them has an "additive" answer. **Wire format:** exactly as `INTUNE_EVIDENCE_SCHEMA_VERSION` states at `crates/cmtraceopen-parser/src/intune/evidence.rs:23-24` — only an optional field, or a variant on an `intune_raw_preserving_string_enum!` type, is additive. The plain `#[derive(Deserialize)]` enums (`IntuneTimestampKind`, `IntuneSensitivity`, `IntuneParseState`, `IntuneAccessState`, `IntuneArtifactStatus`) have no `#[serde(other)]` arm, so an unknown variant is a hard decode error: adding one breaks older readers and bumps the schema version. **Rust API:** no additive guarantee at all. None of these types are `#[non_exhaustive]` and `cmtraceopen-parser` is published, so a new variant or public field breaks downstream `match` arms and struct literals and needs a semver-major release, or `#[non_exhaustive]` first. | +| `IntuneAccessState` <-> `IntuneArtifactStatus` mapping, both directions | `crates/cmtraceopen-parser/src/intune/evidence.rs` (`artifact_status_for_access_state`, `access_state_for_artifact_status`) | A total 7-arm bijection. Exhaustive `match`, no `_` arm, so a new variant is a compile error rather than a silently defaulted status. Was three byte-identical copies before Framework v1 PR 4. | +| Redaction **grammar** (which byte patterns are masked and how) | `crates/cmtraceopen-parser/src/intune/apps/windows/common/redaction.rs` (`redact_text`) | One owner. A local fork previously reintroduced a fixed JSON-escaped-path bug; see `crates/cmtraceopen-parser/src/intune/apps/windows/win32/redaction.rs` lines 9-15. | +| Fixture corpus envelope: manifest version, path safety, byte-count truth, file/manifest closure, synthetic marker, privacy scan | `crates/cmtraceopen-parser/tests/support/mod.rs` | Every Intune corpus. | +| Fixture `captureState` -> library vocabulary | `crates/cmtraceopen-parser/tests/support/mod.rs` (`artifact_status_for_capture_state`, `access_state_for_capture_state`) | The collector boundary the fixtures stand in for. The access-state form is the status form composed with the crate's own mapping, so a fixture can never disagree with the crate about what `parseFailed` means. | +| The ADR-001 *directional doctrine* as prose | `docs/architecture/decisions/ADR-001-evidence-strength-confidence.md` (addendum) | The sentence is shared. The mechanism is not; see below. | + +## Protected divergences + +### 1. A `Capped` artifact may still prove a terminal outcome (Win32 only) + +- **Win32:** truncation removes records, it does not corrupt the ones that framed + completely. A complete framed record inside a capped artifact is authentic + evidence and may prove a terminal outcome, including `Succeeded`. What capping + costs is *confidence*: `High` is demoted to `Medium`, never more. + - `crates/cmtraceopen-parser/src/intune/apps/windows/win32/reducer.rs` ~1687-1716 (`confidence_for`). + - Test: `a_capped_artifact_proves_a_terminal_outcome_only_at_demoted_confidence`, + same file ~2262-2286. +- **Autopilot / Compliance:** the opposite. Assessability is an allowlist + (`access_state == Available && parse_state == Parsed`), so `Capped` is simply + not assessable and proves nothing. + - `crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs` ~248-256; `crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs` + ~447-455. +- **Why both are right:** Win32 evidence is CCM-framed log records with explicit + record boundaries, so "this record is complete" is a checkable property of the + bytes. Autopilot and Compliance reduce documents and reports where a truncated + input has no per-record completeness signal at all. The predicate differs + because the *evidence* differs, not because one lane is sloppier. + +### 2. Autopilot: `time_basis` is deliberately ungated + +Assessability is the admission rule for the Autopilot reducer's status-bearing +paths, but it is not applied uniformly, and the departures are deliberate: + +- **Gated** (the default): status, phase, and signal reduction filter on + `is_assessable` / `is_assessable_section` before reading an observation. +- **Deliberately inverted:** the coverage-gap paths select on + `!is_assessable_section` and `!is_assessable` precisely so unusable input is + reported as a gap rather than silently dropped. +- **Deliberately ungated:** `time_basis` (this section) and + `AutopilotKeyGate::Detecting` (section 3). + +`time_basis` is ungated in the conservative direction: an unfiltered scan lets a +non-assessable record with an unnormalized timestamp *downgrade* the basis. +Gating it would remove that record from consideration and thereby **upgrade** +the reported basis on thinner evidence. + +- `crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs:541` + (the unfiltered `all_normalized` scan), with the rationale stated at + `:604` on `is_assessable` itself, and the inverted coverage-gap + filters at `:672` and `:705`. +- Adding the gate here for symmetry would be a behavior regression that no + compile error would catch. + +### 3. Autopilot: `AutopilotKeyGate::Detecting` reads every observation + +Key linkage runs two passes with different admission rules, on purpose: + +- `Proving` (assessable observations only): these keys may establish a link, the + confident `Linked` state, and the `Completed` outcome behind it. +- `Detecting` (every observation): these keys may only *detect conflicts* and can + never upgrade a linkage on their own. + +More evidence is the conservative direction for conflict detection, and the +restrictive direction for proof. Collapsing the two passes into one gate breaks +whichever half it is collapsed toward. + +- `crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/reducer.rs` ~1311-1327 (enum + doc), applied ~1349-1352, both + passes ~1426-1452. + +### 4. Configuration admits `IntuneParseState::Raw` as interpretable + +Configuration's `is_uninterpretable` excludes only `Malformed` and `Unsupported` +(plus partially-read sources); `Raw` falls through as interpretable. Autopilot +and Compliance both require `Parsed` exactly. + +- Configuration: `crates/cmtraceopen-parser/src/intune/device/windows/configuration/sources.rs` ~146-151. +- Autopilot: `crates/cmtraceopen-parser/src/intune/enrollment/windows/autopilot/models.rs` ~253-256. +- Compliance: `crates/cmtraceopen-parser/src/intune/device/windows/compliance/sources.rs` ~447-455. +- **Why:** Configuration's `Raw` retains a record whose typed fields were not + modelled but whose structure was read. For that lane, a retained raw setting + report is usable input. For Autopilot, `Raw` means the document never declared + what it was, which is exactly the case the explicit-schema-detection rule + exists to refuse. + +### 5. Store and Win32 disagree about what degradation costs, by contract + +| | What degrades | Degraded confidence | +|---|---|---| +| Microsoft Store | only `IntuneParseState::Malformed` (with a non-`Available` access state as a separate contributor) | `Low` | +| Win32 | degraded coverage generally | `Medium` (demotion from `High` only) | + +- Store: `crates/cmtraceopen-parser/src/intune/apps/windows/microsoft_store/reducer.rs` ~857-858 (contributors), + ~905-909 (call site), ~956-972 (`degraded` forces `Low`). +- Win32: `crates/cmtraceopen-parser/src/intune/apps/windows/win32/reducer.rs` ~1711-1715. + +Both are correct against their own contract. Store transactions are assembled +from OS event channels where a malformed contributor means the channel itself is +suspect; Win32 transactions are assembled from framed log records where a +degraded artifact still yields intact records (see divergence 1). Making the two +constants agree would silently rewrite one lane's contract. + +### 6. Linkage and supersession: four different source contracts, never unify + +| Lane | Contract | +|---|---| +| Microsoft Store | asymmetric `activity_id`; no cross-artifact linkage; pairwise only | +| Autopilot | orderless `activity_id` **sets** | +| Win32 | two grammars plus transitive closure | +| Configuration | refuses to link and returns `Contested` | + +These are not four implementations of one idea. They are four different answers +to "what does the source actually guarantee about correlation identifiers", +grounded in ADR-002. A shared linkage helper would have to be the loosest of the +four, which would let Store observations merge across artifacts and let +Configuration produce a link its evidence does not support. + +### 7. Redaction: the grammar is shared, the projection is not + +The masking grammar has exactly one owner +(`crates/cmtraceopen-parser/src/intune/apps/windows/common/redaction.rs`, `redact_text`). What each lane +*classifies as sensitive*, and what its redacted-export equality scope is, is a +property of that analyzer's contract and is not shared. + +The dividing line is stated in `crates/cmtraceopen-parser/src/intune/apps/windows/win32/redaction.rs` lines +9-15: the module "owns only the *projection*: which Win32 fields are classified +sensitive is a property of this analyzer's contract, not of the shared grammar." +The same file records why the grammar has one owner: a local fork of the rules +previously reintroduced an already-fixed JSON-escaped-path bug. + +### 8. Test helpers: `evidence_ids` ordering is per-corpus + +`crates/cmtraceopen-parser/tests/support/mod.rs` provides `sorted_evidence_ids`, named for what it does, +for consumers whose contract is the *set* of citations. Compliance uses it and +sorts both sides of every such assertion. + +Microsoft Store keeps its own insertion-ordered, panicking version in +`crates/cmtraceopen-parser/tests/intune_windows_microsoft_store.rs`, because that leaf asserts citation +*order*: a transaction's `evidence` is positionally in step with its +`observations`. Forcing both corpora onto one helper would retire that pairing +without any test failing. + +Autopilot likewise keeps its own `capture_state` helper: its input type is the +lane-local `AutopilotCaptureState`, and the mapping is serde's own +`rename_all = "camelCase"` rather than a hand-written table. + +## Extraction test + +Before proposing that something move into shared code, it must pass all three: + +1. **Byte-identical today** across every copy, verified arm by arm rather than by + shape. +2. **Identical for the same reason**, not by coincidence. Two lanes that agree + because neither has met the disagreeing case yet are not shared. +3. **A future divergence would be a bug, not a contract**, so that making the + shared version harder to diverge from is a feature rather than an obstacle. + +The `IntuneAccessState` <-> `IntuneArtifactStatus` mapping passes all three. Most +of what looks shared in this family fails test 2.