Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<StoreObservation>) {
let Some(payload) = &artifact.payload else {
return;
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
68 changes: 68 additions & 0 deletions crates/cmtraceopen-parser/src/intune/evidence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 12 additions & 6 deletions crates/cmtraceopen-parser/tests/intune_windows_autopilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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}"))
Expand Down Expand Up @@ -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]
Expand Down
45 changes: 11 additions & 34 deletions crates/cmtraceopen-parser/tests/intune_windows_compliance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
Expand All @@ -97,7 +86,9 @@ fn sources(root: &Path, manifest: &Value) -> Vec<ComplianceSourceInput> {
.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")
Expand Down Expand Up @@ -147,20 +138,6 @@ fn families(manifest: &Value) -> BTreeMap<String, String> {
.collect()
}

fn evidence_ids(value: &Value) -> Vec<String> {
value
.as_array()
.map(|items| {
let mut ids = items
.iter()
.map(|item| item["evidenceId"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>();
ids.sort();
ids
})
.unwrap_or_default()
}

fn artifact_ids(value: &Value) -> Vec<String> {
value
.as_array()
Expand Down Expand Up @@ -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"
);
Expand All @@ -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"
);
Expand Down Expand Up @@ -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"
);
Expand Down
Loading