diff --git a/crates/cmtraceopen-parser/src/sccm/catalog.rs b/crates/cmtraceopen-parser/src/sccm/catalog.rs index 370a4fee5..9d09da1e4 100644 --- a/crates/cmtraceopen-parser/src/sccm/catalog.rs +++ b/crates/cmtraceopen-parser/src/sccm/catalog.rs @@ -180,6 +180,12 @@ const SOURCE_CATALOG: &[CatalogSpec] = &[ role: SccmRole::Client, family: SccmArtifactFamily::ClientPolicy, }, + CatalogSpec { + basename: "StateMessage", + logical_name: "stateMessage", + role: SccmRole::Client, + family: SccmArtifactFamily::ClientPolicy, + }, CatalogSpec { basename: "CAS", logical_name: "cas", diff --git a/crates/cmtraceopen-parser/src/sccm/client/deployment.rs b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs new file mode 100644 index 000000000..1aa17ca85 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/deployment.rs @@ -0,0 +1,2262 @@ +//! Issue #322: application, package, and content deployment transactions. +//! +//! The reducer is pure. It turns a normalized client bundle into conservative +//! transactions whose every claim cites complete logical records. It never +//! reads the file system, never contacts a server, and never states a +//! distribution-point or site-server cause: the only cross-side output is a +//! counterpart-ready client content request that issue #333 may later match. +//! +//! Deployment state chain: +//! +//! ```text +//! Intent -> Requirements -> LocateContent -> Transfer -> Cache -> Enforce -> Detect -> Report +//! ``` + +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Serialize; + +use crate::models::log_entry::Severity; +use crate::sccm::{ + classify_artifact_name, SccmArtifact, SccmArtifactRequest, SccmConfidence, SccmCoverageState, + SccmEvidence, SccmEvidenceRef, SccmFinding, SccmFindingBuilder, SccmFindingClass, + SccmFindingCoverageGap, SccmPhase, SccmRecordCompleteness, SccmRole, SccmRotation, + SccmTerminalEvidence, SccmTimeOrderingState, +}; + +use super::SccmNormalizedBundle; + +pub const SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION: u32 = 1; +pub const SCCM_DEPLOYMENT_TEST_PROFILE_ID: &str = "deployment-client-5.00.test-v1"; +pub const SCCM_DEPLOYMENT_TEST_VERSION_PREFIX: &str = "5.00.TEST."; + +const GROUP_APP_INTENT: &str = "client-app-intent"; +const GROUP_APP_ENFORCE: &str = "client-app-enforce"; +const GROUP_CONTENT: &str = "client-content"; +const GROUP_POLICY_STATE: &str = "client-policy-state"; + +const REASON_LOCATION_RESPONSE_MISSING: &str = + "capture a complete terminal location response; a client request alone cannot prove DP content state"; +const REASON_LOCATION_ACCESS_DENIED: &str = + "access denied is a coverage state, not proof of content success or failure"; +const REASON_LOCATION_ROTATION: &str = + "capture a complete logical CCM content record without joining physical rotation fragments"; +const REASON_LOCATION_ABSENT: &str = + "Capture an exact client content-location response for this assignment and CI."; +const REASON_INTENT: &str = + "capture the complete client application intent record for this assignment and CI"; +const REASON_REQUIREMENTS: &str = + "capture the complete client requirement and dependency outcome for this assignment and CI"; +const REASON_TRANSFER: &str = "capture the complete client content transfer outcome for this key"; +const REASON_CACHE: &str = "capture the complete client cache commit outcome for this key"; +const REASON_ENFORCE: &str = "capture the complete client enforcement outcome for this key"; +const REASON_DETECT: &str = "capture the complete client detection outcome for this key"; +const REASON_REPORT: &str = "capture the complete client deployment state report for this key"; +const REASON_CHRONOLOGY: &str = + "capture records whose timestamps can be ordered against the earlier phases of this key"; + +const COUNTERPART_READY_KEY_KINDS: [&str; 5] = [ + "contentId", + "contentVersion", + "distributionPointHostHandle", + "packageId", + "requestId", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentWorkflow { + Deployment, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentPhase { + Intent, + Requirements, + LocateContent, + Transfer, + Cache, + Enforce, + Detect, + Report, +} + +impl SccmDeploymentPhase { + pub fn as_str(self) -> &'static str { + match self { + Self::Intent => "intent", + Self::Requirements => "requirements", + Self::LocateContent => "locateContent", + Self::Transfer => "transfer", + Self::Cache => "cache", + Self::Enforce => "enforce", + Self::Detect => "detect", + Self::Report => "report", + } + } + + fn artifact_group(self) -> &'static str { + match self { + Self::Intent | Self::Requirements | Self::Detect => GROUP_APP_INTENT, + Self::LocateContent | Self::Transfer | Self::Cache => GROUP_CONTENT, + Self::Enforce => GROUP_APP_ENFORCE, + Self::Report => GROUP_POLICY_STATE, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentState { + NotTargeted, + InsufficientEvidence, + Failed, + DetectionMismatch, + Succeeded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentClassification { + NotTargeted, + InsufficientEvidence, + Symptom, + ConfirmedFailure, + Success, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentConfidence { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentKeyProfileKind { + AssignmentCi, + AssignmentCiContentTopology, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentKeyConfidence { + Candidate, + Exact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentTimestampProvenanceKind { + ExplicitOffset, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentCounterpartFactKind { + ClientContentRequest, +} + +/// Exact, version-profiled identity of one deployment transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentKey { + pub key_profile_kind: SccmDeploymentKeyProfileKind, + pub assignment_id: String, + pub ci_id: String, + pub package_id: Option, + pub content_id: Option, + pub content_version: Option, + pub distribution_point_host_handle: Option, + pub request_id: Option, + pub bits_job_id: Option, + pub product_code: Option, + pub exit_code: Option, + pub confidence: SccmDeploymentKeyConfidence, + pub extraction_profile_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentTimestampProvenance { + pub kind: SccmDeploymentTimestampProvenanceKind, + pub offset_minutes: i32, + pub normalized_utc: String, +} + +/// The only issue #333 handoff this reducer produces. +/// +/// It restates an exact client-side content request. It is not a distribution +/// point observation and carries no claim about a server outcome. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentCounterpartFact { + pub fact_kind: SccmDeploymentCounterpartFactKind, + pub phase: SccmDeploymentPhase, + pub extraction_profile_id: String, + pub package_id: String, + pub content_id: String, + pub content_version: u32, + pub distribution_point_host_handle: String, + pub request_id: String, + pub timestamp_provenance: SccmDeploymentTimestampProvenance, + pub evidence: SccmEvidenceRef, +} + +/// The smallest next evidence bundle, named by deployment artifact group. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentArtifactRequest { + pub logical_artifact_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentTransaction { + pub transaction_id: String, + pub key: SccmDeploymentKey, + pub counterpart_ready_fact: Option, + pub phase: SccmDeploymentPhase, + pub state: SccmDeploymentState, + pub last_successful_phase: Option, + pub classification: SccmDeploymentClassification, + pub confidence: SccmDeploymentConfidence, + pub confidence_ceiling: SccmDeploymentConfidence, + pub coverage_gap_artifact_ids: Vec, + pub next_artifact: Option, + pub evidence: Vec, +} + +/// Coverage of one deployment artifact group. Absence is a state, never proof. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentCoverage { + pub logical_artifact_id: String, + pub state: SccmCoverageState, + pub artifact_ids: Vec, +} + +/// Whether any collected client source declared the version this profile reads. +/// +/// Selection is a statement about the sources, not about the diagnosis: a +/// selected profile that read nothing still validates no family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentProfileSelectionState { + Selected, + Unselected, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentExtractionProfile { + pub selection_state: SccmDeploymentProfileSelectionState, + pub profile_id: String, + pub source_version_prefix: String, + pub content_version_required: bool, + pub key_kinds: Vec, + pub validated_artifact_families: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentCorrelationHandoff { + pub issue: String, + pub performed: bool, + pub time_only_eligible: bool, + pub topology_compatibility_evaluated: bool, + pub server_cause_claimed: bool, + pub counterpart_ready_key_kinds: Vec, + pub emitted_counterpart_ready_fact: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmDeploymentObservationKeyConfidence { + None, + Candidate, +} + +/// Bytes that were seen but can never become a fact. +/// +/// A fragment, a capped tail, or an unvalidated supplemental installer line +/// stays here: it is capped at Low confidence and is never correlation +/// eligible, so it can neither override nor join a keyed transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentObservation { + pub observation_id: String, + pub artifact_id: String, + pub complete_logical_record: bool, + pub key_confidence: SccmDeploymentObservationKeyConfidence, + pub confidence_ceiling: SccmDeploymentConfidence, + pub correlation_eligible: bool, + pub reason: String, + pub evidence: SccmEvidenceRef, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentFinding { + #[serde(flatten)] + pub finding: SccmFinding, + pub deployment_phase: SccmDeploymentPhase, + pub last_successful_phase: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SccmDeploymentAnalysis { + pub schema_version: u32, + pub workflow: SccmDeploymentWorkflow, + pub extraction_profile: SccmDeploymentExtractionProfile, + pub coverage: Vec, + pub transactions: Vec, + pub source_local_observations: Vec, + pub findings: Vec, + pub coverage_gaps: Vec, + pub artifact_requests: Vec, + pub correlation_handoff: SccmDeploymentCorrelationHandoff, +} + +/// Reduce a normalized client bundle into deployment transactions. +pub fn analyze_client_deployment(bundle: &SccmNormalizedBundle) -> SccmDeploymentAnalysis { + let coverage = coverage_rows(bundle); + + if bundle_identity_collides(bundle) { + return finalize( + selection_state(bundle), + coverage, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ); + } + + // Only client-role artifacts may participate. Building this map from the + // full artifact list would let another role's identical artifact ID decide + // which source a record came from. + let artifacts_by_id = bundle + .artifacts + .iter() + .filter(|artifact| artifact.role == SccmRole::Client) + .map(|artifact| (artifact.artifact_id.as_str(), artifact)) + .collect::>(); + + let mut facts = bundle + .evidence + .iter() + .filter(|evidence| evidence.role == SccmRole::Client) + .flat_map(|evidence| { + artifacts_by_id + .get(evidence.reference.artifact_id.as_str()) + .map(|artifact| parse_deployment_facts(evidence, artifact)) + .unwrap_or_default() + }) + .collect::>(); + facts.sort_by(|left, right| compare_references(&left.reference, &right.reference)); + + let mut by_assignment = BTreeMap::<&str, Vec<&DeploymentFact>>::new(); + for fact in &facts { + by_assignment + .entry(fact.assignment_id.as_str()) + .or_default() + .push(fact); + } + + let mut transactions = Vec::new(); + let mut seeds = Vec::new(); + for (assignment_id, assignment_facts) in by_assignment { + let Some((transaction, seed)) = + build_transaction(assignment_id, &assignment_facts, &coverage) + else { + continue; + }; + transactions.push(transaction); + if let Some(seed) = seed { + seeds.push(seed); + } + } + + let admitted_artifact_ids = facts + .iter() + .map(|fact| fact.reference.artifact_id.as_str()) + .collect::>(); + // A family counts as validated only where the profile actually read a + // record. Captured bytes it could not read prove collection, not coverage + // of the workflow. + let validated_artifact_families = admitted_artifact_ids + .iter() + .filter_map(|artifact_id| artifacts_by_id.get(artifact_id)) + .map(|artifact| deployment_group_id(&artifact.display_name)) + .collect::>() + .into_iter() + .collect::>(); + let observations = source_local_observations(bundle, &artifacts_by_id, &admitted_artifact_ids); + let findings = build_findings(&seeds, &artifacts_by_id); + + finalize( + selection_state(bundle), + coverage, + transactions, + observations, + findings, + validated_artifact_families, + ) +} + +fn selection_state(bundle: &SccmNormalizedBundle) -> SccmDeploymentProfileSelectionState { + let declared = bundle.artifacts.iter().any(|artifact| { + artifact.role == SccmRole::Client + && artifact + .configmgr_version + .as_deref() + .is_some_and(|version| version.starts_with(SCCM_DEPLOYMENT_TEST_VERSION_PREFIX)) + }); + if declared { + SccmDeploymentProfileSelectionState::Selected + } else { + SccmDeploymentProfileSelectionState::Unselected + } +} + +fn finalize( + selection_state: SccmDeploymentProfileSelectionState, + coverage: Vec, + transactions: Vec, + source_local_observations: Vec, + findings: Vec, + validated_artifact_families: Vec, +) -> SccmDeploymentAnalysis { + let emitted_counterpart_ready_fact = transactions + .iter() + .any(|transaction| transaction.counterpart_ready_fact.is_some()); + + let mut coverage_gaps = findings + .iter() + .flat_map(|finding| finding.finding.coverage_gaps.iter().cloned()) + .collect::>(); + coverage_gaps.sort_by(|left, right| { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| coverage_order(&left.coverage).cmp(&coverage_order(&right.coverage))) + }); + coverage_gaps.dedup(); + + let mut artifact_requests = findings + .iter() + .flat_map(|finding| finding.finding.next_artifacts.iter().cloned()) + .collect::>(); + artifact_requests.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then_with(|| left.reason.cmp(&right.reason)) + }); + artifact_requests.dedup(); + + SccmDeploymentAnalysis { + schema_version: SCCM_DEPLOYMENT_ANALYSIS_SCHEMA_VERSION, + workflow: SccmDeploymentWorkflow::Deployment, + extraction_profile: extraction_profile( + selection_state, + validated_artifact_families, + &transactions, + ), + coverage, + transactions, + source_local_observations, + findings, + coverage_gaps, + artifact_requests, + correlation_handoff: SccmDeploymentCorrelationHandoff { + issue: "#333".to_owned(), + performed: false, + time_only_eligible: false, + topology_compatibility_evaluated: false, + server_cause_claimed: false, + counterpart_ready_key_kinds: COUNTERPART_READY_KEY_KINDS + .iter() + .map(|kind| (*kind).to_owned()) + .collect(), + emitted_counterpart_ready_fact, + }, + } +} + +fn extraction_profile( + selection_state: SccmDeploymentProfileSelectionState, + validated_artifact_families: Vec, + transactions: &[SccmDeploymentTransaction], +) -> SccmDeploymentExtractionProfile { + let mut key_kinds = BTreeSet::new(); + for transaction in transactions { + key_kinds.insert("assignmentId"); + key_kinds.insert("ciId"); + for (kind, present) in [ + ("packageId", transaction.key.package_id.is_some()), + ("contentId", transaction.key.content_id.is_some()), + ("contentVersion", transaction.key.content_version.is_some()), + ( + "distributionPointHostHandle", + transaction.key.distribution_point_host_handle.is_some(), + ), + ("requestId", transaction.key.request_id.is_some()), + ("bitsJobId", transaction.key.bits_job_id.is_some()), + ("productCode", transaction.key.product_code.is_some()), + ("exitCode", transaction.key.exit_code.is_some()), + ] { + if present { + key_kinds.insert(kind); + } + } + } + + SccmDeploymentExtractionProfile { + selection_state, + profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + source_version_prefix: SCCM_DEPLOYMENT_TEST_VERSION_PREFIX.to_owned(), + content_version_required: true, + key_kinds: key_kinds.into_iter().map(str::to_owned).collect(), + validated_artifact_families, + } +} + +// --------------------------------------------------------------------------- +// Identity guards +// --------------------------------------------------------------------------- + +/// Duplicate artifact or evidence identities make source authority ambiguous. +/// The reducer then reports coverage only: vector order must never elect one. +fn bundle_identity_collides(bundle: &SccmNormalizedBundle) -> bool { + let mut artifact_ids = BTreeSet::new(); + for artifact in bundle + .artifacts + .iter() + .filter(|artifact| artifact.role == SccmRole::Client) + { + if !artifact_ids.insert(artifact.artifact_id.as_str()) { + return true; + } + } + + let mut evidence_ids = BTreeSet::new(); + let mut references = BTreeSet::new(); + for evidence in bundle + .evidence + .iter() + .filter(|evidence| evidence.role == SccmRole::Client) + { + if !evidence_ids.insert(evidence.evidence_id.as_str()) { + return true; + } + if !references.insert(( + evidence.reference.artifact_id.as_str(), + evidence.reference.entry_id.as_str(), + evidence.reference.line_start, + evidence.reference.line_end, + )) { + return true; + } + } + + false +} + +// --------------------------------------------------------------------------- +// Coverage +// --------------------------------------------------------------------------- + +fn coverage_rows(bundle: &SccmNormalizedBundle) -> Vec { + let mut grouped = BTreeMap::>::new(); + for artifact in bundle + .artifacts + .iter() + .filter(|artifact| artifact.role == SccmRole::Client) + { + grouped + .entry(deployment_group_id(&artifact.display_name)) + .or_default() + .push(artifact); + } + + grouped + .into_iter() + .map(|(logical_artifact_id, artifacts)| { + let states = artifacts + .iter() + .map(|artifact| artifact.coverage.clone()) + .collect::>(); + let mut artifact_ids = artifacts + .iter() + .filter(|artifact| artifact.coverage != SccmCoverageState::Captured) + .map(|artifact| artifact.artifact_id.clone()) + .collect::>(); + artifact_ids.sort(); + artifact_ids.dedup(); + + SccmDeploymentCoverage { + logical_artifact_id, + state: combine_coverage(&states), + artifact_ids, + } + }) + .collect() +} + +/// Any complete capture makes the group usable; otherwise the most explanatory +/// incomplete state wins. Conflicting noncapture states stay `ParseFailed` so +/// no caller can read a single cause out of a mixed group. +fn combine_coverage(states: &[SccmCoverageState]) -> SccmCoverageState { + for candidate in [ + SccmCoverageState::Captured, + SccmCoverageState::Capped, + SccmCoverageState::Partial, + ] { + if states.contains(&candidate) { + return candidate; + } + } + + let distinct = states + .iter() + .map(coverage_order) + .collect::>() + .len(); + match (distinct, states.first()) { + (1, Some(state)) => state.clone(), + _ => SccmCoverageState::ParseFailed, + } +} + +fn coverage_order(coverage: &SccmCoverageState) -> u8 { + match coverage { + SccmCoverageState::Captured => 0, + SccmCoverageState::Partial => 1, + SccmCoverageState::Absent => 2, + SccmCoverageState::AccessDenied => 3, + SccmCoverageState::Capped => 4, + SccmCoverageState::Skipped => 5, + SccmCoverageState::Unsupported => 6, + SccmCoverageState::ParseFailed => 7, + } +} + +fn coverage_for_group<'a>( + coverage: &'a [SccmDeploymentCoverage], + group: &str, +) -> Option<&'a SccmDeploymentCoverage> { + coverage.iter().find(|row| row.logical_artifact_id == group) +} + +// --------------------------------------------------------------------------- +// Source classification +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeploymentSourceKind { + Intent, + Discovery, + Enforce, + ContentAccess, + Transfer, + StateReport, +} + +#[derive(Debug, Clone, Copy)] +struct DeploymentSource { + group: &'static str, + kind: DeploymentSourceKind, +} + +/// Exact logical-name table. An unlisted source is never a deployment fact +/// source, no matter how similar its text looks. +fn deployment_source(logical_name: &str) -> Option { + let (group, kind) = match logical_name { + "appIntentEval" => (GROUP_APP_INTENT, DeploymentSourceKind::Intent), + "appDiscovery" => (GROUP_APP_INTENT, DeploymentSourceKind::Discovery), + "appEnforce" => (GROUP_APP_ENFORCE, DeploymentSourceKind::Enforce), + "cas" => (GROUP_CONTENT, DeploymentSourceKind::ContentAccess), + "dataTransferService" | "contentTransferManager" => { + (GROUP_CONTENT, DeploymentSourceKind::Transfer) + } + "stateMessage" => (GROUP_POLICY_STATE, DeploymentSourceKind::StateReport), + _ => return None, + }; + Some(DeploymentSource { group, kind }) +} + +fn deployment_group_id(display_name: &str) -> String { + let catalog = classify_artifact_name(display_name, SccmRole::Client); + match deployment_source(&catalog.logical_name) { + Some(source) => source.group.to_owned(), + None => format!("client-{}", kebab_case(&catalog.logical_name)), + } +} + +fn kebab_case(value: &str) -> String { + let mut result = String::with_capacity(value.len() + 4); + for character in value.chars() { + if character.is_ascii_uppercase() { + if !result.is_empty() { + result.push('-'); + } + result.push(character.to_ascii_lowercase()); + } else { + result.push(character); + } + } + result +} + +// --------------------------------------------------------------------------- +// Facts +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeploymentFactKind { + IntentTargeted, + IntentNotApplicable, + RequirementsSatisfied, + RequirementsFailed, + DependencyFailed, + ContentLocated, + ContentRequested, + TransferStarted, + TransferCompleted, + TransferFailed, + CacheCommitted, + CacheFailed, + EnforceSucceeded, + EnforceFailed, + Detected, + DetectionMismatch, + ReportSucceeded, + ReportFailed, +} + +#[derive(Debug, Clone)] +struct DeploymentFact { + kind: DeploymentFactKind, + reference: SccmEvidenceRef, + utc_millis: Option, + offset_minutes: Option, + time_comparable: bool, + assignment_id: String, + ci_id: Option, + package_id: Option, + content_id: Option, + content_version: Option, + distribution_point_host_handle: Option, + request_id: Option, + bits_job_id: Option, + product_code: Option, + exit_code: Option, +} + +fn parse_deployment_facts(evidence: &SccmEvidence, artifact: &SccmArtifact) -> Vec { + let Some(source) = admitted_source(evidence, artifact) else { + return Vec::new(); + }; + let Some(payload) = deployment_event_payload(&evidence.message) else { + return Vec::new(); + }; + let phrase = event_phrase(payload); + let Some(assignment_id) = + field_value(payload, "assignmentId").filter(|value| valid_guid(value)) + else { + return Vec::new(); + }; + + let kinds = match source.kind { + DeploymentSourceKind::Intent => intent_fact_kinds(&phrase, payload), + DeploymentSourceKind::Discovery => discovery_fact_kinds(&phrase, payload), + DeploymentSourceKind::Enforce => enforce_fact_kinds(&phrase, payload), + DeploymentSourceKind::ContentAccess => content_fact_kinds(&phrase, payload), + DeploymentSourceKind::Transfer => transfer_fact_kinds(&phrase, payload), + DeploymentSourceKind::StateReport => report_fact_kinds(&phrase, payload), + }; + if kinds.is_empty() { + return Vec::new(); + } + + let template = DeploymentFact { + kind: DeploymentFactKind::IntentTargeted, + reference: evidence.reference.clone(), + utc_millis: evidence.timestamp.utc_millis, + offset_minutes: evidence.timestamp.offset_minutes, + time_comparable: evidence.timestamp.ordering_state == SccmTimeOrderingState::NormalizedUtc + && evidence.timestamp.utc_millis.is_some(), + assignment_id: assignment_id.to_owned(), + ci_id: field_value(payload, "ciId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + package_id: field_value(payload, "packageId") + .filter(|value| valid_package_id(value)) + .map(str::to_owned), + content_id: field_value(payload, "contentId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + content_version: field_value(payload, "contentVersion").and_then(parse_content_version), + distribution_point_host_handle: field_value(payload, "distributionPointHostHandle") + .filter(|value| valid_safe_handle(value)) + .map(str::to_owned), + request_id: field_value(payload, "requestId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + bits_job_id: field_value(payload, "bitsJobId") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + product_code: field_value(payload, "productCode") + .filter(|value| valid_guid(value)) + .map(str::to_owned), + exit_code: field_value(payload, "exitCode") + .filter(|value| valid_exit_code(value)) + .map(str::to_owned), + }; + + kinds + .into_iter() + .map(|kind| DeploymentFact { + kind, + ..template.clone() + }) + .collect() +} + +/// Record completeness, version profile, role, coverage, rotation, and catalog +/// identity must all agree before a record may become a fact. +/// +/// Completeness is read from the record, never inferred from the artifact: a +/// fully collected file can still hold a physical line that no logical record +/// covers, and that line is not evidence of anything. +fn admitted_source(evidence: &SccmEvidence, artifact: &SccmArtifact) -> Option { + if evidence.completeness != SccmRecordCompleteness::LogicalRecord + || artifact.role != SccmRole::Client + || evidence.role != SccmRole::Client + || artifact.coverage != SccmCoverageState::Captured + || !valid_reference(&evidence.reference) + { + return None; + } + if !artifact + .configmgr_version + .as_deref() + .is_some_and(|version| version.starts_with(SCCM_DEPLOYMENT_TEST_VERSION_PREFIX)) + { + return None; + } + + let catalog = classify_artifact_name(&artifact.display_name, SccmRole::Client); + if !catalog.supported_for_diagnosis || artifact.rotation != catalog.rotation { + return None; + } + deployment_source(&catalog.logical_name) +} + +fn intent_fact_kinds(phrase: &str, payload: &str) -> Vec { + let mut kinds = Vec::new(); + let state = field_value(payload, "state"); + let terminal = is_terminal(payload); + + if matches!(phrase, "intent" | "targeted" | "requirements satisfied") + && state == Some("targeted") + { + kinds.push(DeploymentFactKind::IntentTargeted); + } + if matches!( + phrase, + "explicitly not targeted" | "not targeted" | "not applicable" + ) && state == Some("notApplicable") + && terminal + { + kinds.push(DeploymentFactKind::IntentNotApplicable); + } + if phrase == "requirements satisfied" { + kinds.push(DeploymentFactKind::RequirementsSatisfied); + } + if phrase == "requirements terminal failure" + && terminal + && field_value(payload, "requirementId").is_some_and(valid_safe_key) + { + kinds.push(DeploymentFactKind::RequirementsFailed); + } + if phrase == "dependency terminal failure" + && terminal + && field_value(payload, "dependencyCiId").is_some_and(valid_guid) + { + kinds.push(DeploymentFactKind::DependencyFailed); + } + kinds +} + +fn discovery_fact_kinds(phrase: &str, payload: &str) -> Vec { + match (phrase, field_value(payload, "detected")) { + ("detected", Some("true")) => vec![DeploymentFactKind::Detected], + ("detection false negative", Some("false")) => vec![DeploymentFactKind::DetectionMismatch], + _ => Vec::new(), + } +} + +/// A nonzero exit code alone stays a symptom. A confirmed enforcement failure +/// needs the terminal marker on the same complete record. +fn enforce_fact_kinds(phrase: &str, payload: &str) -> Vec { + let Some(exit_code) = field_value(payload, "exitCode").filter(|value| valid_exit_code(value)) + else { + return Vec::new(); + }; + if !is_terminal(payload) { + return Vec::new(); + } + match phrase { + "enforcement completed" if exit_code == "0" => vec![DeploymentFactKind::EnforceSucceeded], + "enforcement terminal failure" if exit_code != "0" => { + vec![DeploymentFactKind::EnforceFailed] + } + _ => Vec::new(), + } +} + +fn content_fact_kinds(phrase: &str, payload: &str) -> Vec { + let has_topology = field_value(payload, "packageId").is_some_and(valid_package_id) + && field_value(payload, "contentId").is_some_and(valid_guid) + && field_value(payload, "contentVersion") + .and_then(parse_content_version) + .is_some() + && field_value(payload, "distributionPointHostHandle").is_some_and(valid_safe_handle) + && field_value(payload, "requestId").is_some_and(valid_guid); + + match phrase { + "content located" if has_topology => vec![DeploymentFactKind::ContentLocated], + "content request observed" + if has_topology && field_value(payload, "responseState") == Some("unknown") => + { + vec![DeploymentFactKind::ContentRequested] + } + "cache commit completed" + if field_value(payload, "contentId").is_some_and(valid_guid) + && field_value(payload, "contentVersion") + .and_then(parse_content_version) + .is_some() => + { + vec![DeploymentFactKind::CacheCommitted] + } + "cache commit terminal failure" if is_terminal(payload) && has_nonzero_error(payload) => { + vec![DeploymentFactKind::CacheFailed] + } + _ => Vec::new(), + } +} + +fn transfer_fact_kinds(phrase: &str, payload: &str) -> Vec { + let content_id = field_value(payload, "contentId").is_some_and(valid_guid); + let bits_job_id = field_value(payload, "bitsJobId").is_some_and(valid_guid); + match phrase { + "transfer started" + if content_id + && bits_job_id + && field_value(payload, "requestId").is_some_and(valid_guid) => + { + vec![DeploymentFactKind::TransferStarted] + } + "transfer completed" if content_id && bits_job_id => { + vec![DeploymentFactKind::TransferCompleted] + } + "transfer terminal failure" + if content_id && bits_job_id && is_terminal(payload) && has_nonzero_error(payload) => + { + vec![DeploymentFactKind::TransferFailed] + } + _ => Vec::new(), + } +} + +fn report_fact_kinds(phrase: &str, payload: &str) -> Vec { + match (phrase, field_value(payload, "state")) { + ("reported", Some("succeeded")) => vec![DeploymentFactKind::ReportSucceeded], + ("reported", Some("failed")) => vec![DeploymentFactKind::ReportFailed], + _ => Vec::new(), + } +} + +// --------------------------------------------------------------------------- +// Message grammar +// --------------------------------------------------------------------------- + +const PUBLIC_MESSAGE_PREFIX: &str = "[sccm-public-message-v1] "; +const EVENT_QUALIFIERS: [&str; 4] = ["SYNTHETIC", "FIXTURE", "deployment", "success"]; + +fn deployment_event_payload(message: &str) -> Option<&str> { + message.strip_prefix(PUBLIC_MESSAGE_PREFIX) +} + +/// The leading clause of a record, with documented capture and scope +/// qualifiers removed. Only a phrase that starts the clause selects an event, +/// so an embedded label such as `Base requirements satisfied` never matches +/// `requirements satisfied`. +fn event_phrase(payload: &str) -> String { + payload + .split_whitespace() + .take_while(|word| !word.contains('=')) + .skip_while(|word| EVENT_QUALIFIERS.contains(word)) + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +/// Exact-token lookup. A duplicated label, or a label that is only the tail of +/// a longer token, yields nothing rather than a guess. +/// +/// Every occurrence is counted before any boundary rule is applied. Filtering +/// first would silently discard a punctuation-adjacent conflict such as +/// `terminal=true (terminal=false)` and let the first value win. +fn field_value<'a>(message: &'a str, key: &str) -> Option<&'a str> { + let marker = format!("{key}="); + let mut occurrences = message.match_indices(&marker); + let (first, _) = occurrences.next()?; + if occurrences.next().is_some() { + return None; + } + if first != 0 && !message.as_bytes()[first - 1].is_ascii_whitespace() { + return None; + } + + let value_start = first + marker.len(); + let value = &message[value_start..]; + let value_end = value + .find(|character: char| character.is_ascii_whitespace() || matches!(character, ',' | ';')) + .unwrap_or(value.len()); + (!value[..value_end].is_empty()).then_some(&value[..value_end]) +} + +fn is_terminal(payload: &str) -> bool { + field_value(payload, "terminal") == Some("true") +} + +fn has_nonzero_error(payload: &str) -> bool { + field_value(payload, "errorCode") + .and_then(parse_hex_u32) + .is_some_and(|value| value != 0) +} + +fn parse_hex_u32(value: &str) -> Option { + let hex = value.strip_prefix("0x")?; + (hex.len() == 8 && hex.chars().all(|character| character.is_ascii_hexdigit())) + .then(|| u32::from_str_radix(hex, 16).ok()) + .flatten() +} + +fn parse_content_version(value: &str) -> Option { + if value.len() > 1 && value.starts_with('0') { + return None; + } + value + .chars() + .all(|character| character.is_ascii_digit()) + .then(|| value.parse::().ok()) + .flatten() +} + +fn valid_exit_code(value: &str) -> bool { + parse_content_version(value).is_some() +} + +fn valid_guid(value: &str) -> bool { + value.len() == 36 + && value.chars().enumerate().all(|(index, character)| { + if matches!(index, 8 | 13 | 18 | 23) { + character == '-' + } else { + character.is_ascii_hexdigit() + } + }) +} + +fn valid_package_id(value: &str) -> bool { + value.len() == 8 + && value + .chars() + .all(|character| character.is_ascii_uppercase() || character.is_ascii_digit()) +} + +/// Distribution point identities stay opaque handles. A raw host name is never +/// admitted, so no public output can carry a real server name. +fn valid_safe_handle(value: &str) -> bool { + let Some(body) = value.strip_prefix("safe:") else { + return false; + }; + !body.is_empty() + && body.len() <= 128 + && body.split(':').all(|segment| { + !segment.is_empty() + && !segment.starts_with('-') + && !segment.ends_with('-') + && segment.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + }) +} + +fn valid_safe_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '-' + }) +} + +fn valid_reference(reference: &SccmEvidenceRef) -> bool { + valid_public_id(&reference.artifact_id) + && valid_public_id(&reference.entry_id) + && matches!( + (reference.line_start, reference.line_end), + (Some(start), Some(end)) if start > 0 && end >= start + ) +} + +fn valid_public_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | ':') + }) +} + +// --------------------------------------------------------------------------- +// Chronology +// --------------------------------------------------------------------------- + +/// Records in one physical artifact order by line. Records in different +/// artifacts order only when both carry a valid offset. Anything else is +/// incomparable and must downgrade confidence, never assume a sequence. +fn compare_fact_order(left: &DeploymentFact, right: &DeploymentFact) -> Option { + if left.reference.artifact_id == right.reference.artifact_id { + return Some( + left.reference + .line_start + .cmp(&right.reference.line_start) + .then_with(|| left.reference.line_end.cmp(&right.reference.line_end)), + ); + } + if left.time_comparable && right.time_comparable { + return Some(left.utc_millis.cmp(&right.utc_millis)); + } + None +} + +fn fact_is_strictly_before(earlier: &DeploymentFact, later: &DeploymentFact) -> bool { + compare_fact_order(earlier, later) == Some(Ordering::Less) +} + +fn chain_has_usable_order(facts: &[&DeploymentFact]) -> bool { + facts + .windows(2) + .all(|pair| fact_is_strictly_before(pair[0], pair[1])) +} + +fn compare_references(left: &SccmEvidenceRef, right: &SccmEvidenceRef) -> Ordering { + left.artifact_id + .cmp(&right.artifact_id) + .then_with(|| left.line_start.cmp(&right.line_start)) + .then_with(|| left.line_end.cmp(&right.line_end)) + .then_with(|| left.entry_id.cmp(&right.entry_id)) +} + +// --------------------------------------------------------------------------- +// Transaction composition +// --------------------------------------------------------------------------- + +struct Outcome { + phase: SccmDeploymentPhase, + state: SccmDeploymentState, + classification: SccmDeploymentClassification, + confidence: SccmDeploymentConfidence, + last_successful_phase: Option, + next_artifact: Option, + coverage_gap_artifact_ids: Vec, + finding_id: Option<&'static str>, + terminal_evidence: Vec, +} + +/// One transaction's contribution to a bundle-level finding. +struct FindingSeed { + finding_id: &'static str, + class: SccmFindingClass, + phase: SccmDeploymentPhase, + confidence: SccmConfidence, + last_successful_phase: Option, + evidence: Vec, + terminal_evidence: Vec, + coverage_gap_artifact_ids: Vec, + coverage_gap_group: Option<&'static str>, + request_phase: Option, +} + +fn build_transaction( + assignment_id: &str, + facts: &[&DeploymentFact], + coverage: &[SccmDeploymentCoverage], +) -> Option<(SccmDeploymentTransaction, Option)> { + let ci_id = unique_value(facts.iter().filter_map(|fact| fact.ci_id.clone()))?; + let key = build_key(assignment_id, &ci_id, facts); + let outcome = resolve_outcome(facts, coverage); + let evidence = merged_evidence(facts); + + let seed = outcome.finding_id.map(|finding_id| FindingSeed { + finding_id, + class: match outcome.classification { + SccmDeploymentClassification::ConfirmedFailure => SccmFindingClass::ConfirmedFailure, + SccmDeploymentClassification::Symptom => SccmFindingClass::Symptom, + _ => SccmFindingClass::InsufficientEvidence, + }, + phase: outcome.phase, + confidence: match outcome.confidence { + SccmDeploymentConfidence::High => SccmConfidence::High, + SccmDeploymentConfidence::Medium => SccmConfidence::Moderate, + SccmDeploymentConfidence::Low => SccmConfidence::Low, + }, + last_successful_phase: outcome.last_successful_phase, + evidence: if outcome.terminal_evidence.is_empty() { + evidence.clone() + } else { + outcome.terminal_evidence.clone() + }, + terminal_evidence: outcome.terminal_evidence.clone(), + coverage_gap_artifact_ids: outcome.coverage_gap_artifact_ids.clone(), + coverage_gap_group: (outcome.classification + == SccmDeploymentClassification::InsufficientEvidence) + .then(|| outcome.phase.artifact_group()), + request_phase: outcome.next_artifact.as_ref().map(|_| outcome.phase), + }); + + Some(( + SccmDeploymentTransaction { + transaction_id: format!("deployment:assignment:{assignment_id}"), + counterpart_ready_fact: counterpart_ready_fact(facts, &key), + key, + phase: outcome.phase, + state: outcome.state, + last_successful_phase: outcome.last_successful_phase, + classification: outcome.classification, + confidence: outcome.confidence, + confidence_ceiling: outcome.confidence, + coverage_gap_artifact_ids: outcome.coverage_gap_artifact_ids, + next_artifact: outcome.next_artifact, + evidence, + }, + seed, + )) +} + +/// Distinct values collapse to one key only when they agree. Two different +/// values are ambiguity, not a choice. +fn unique_value(values: impl Iterator) -> Option { + let mut distinct = values.collect::>(); + (distinct.len() == 1) + .then(|| distinct.pop_first()) + .flatten() +} + +fn build_key(assignment_id: &str, ci_id: &str, facts: &[&DeploymentFact]) -> SccmDeploymentKey { + let package_id = unique_value(facts.iter().filter_map(|fact| fact.package_id.clone())); + let content_id = unique_value(facts.iter().filter_map(|fact| fact.content_id.clone())); + let content_version = unique_value(facts.iter().filter_map(|fact| fact.content_version)); + let distribution_point_host_handle = unique_value( + facts + .iter() + .filter_map(|fact| fact.distribution_point_host_handle.clone()), + ); + let request_id = unique_value(facts.iter().filter_map(|fact| fact.request_id.clone())); + let has_topology = package_id.is_some() + && content_id.is_some() + && content_version.is_some() + && distribution_point_host_handle.is_some() + && request_id.is_some(); + + SccmDeploymentKey { + key_profile_kind: if has_topology { + SccmDeploymentKeyProfileKind::AssignmentCiContentTopology + } else { + SccmDeploymentKeyProfileKind::AssignmentCi + }, + assignment_id: assignment_id.to_owned(), + ci_id: ci_id.to_owned(), + package_id, + content_id, + content_version, + distribution_point_host_handle, + request_id, + bits_job_id: unique_value(facts.iter().filter_map(|fact| fact.bits_job_id.clone())), + product_code: unique_value(facts.iter().filter_map(|fact| fact.product_code.clone())), + exit_code: unique_value( + facts + .iter() + .filter(|fact| { + matches!( + fact.kind, + DeploymentFactKind::EnforceSucceeded | DeploymentFactKind::EnforceFailed + ) + }) + .filter_map(|fact| fact.exit_code.clone()), + ), + confidence: SccmDeploymentKeyConfidence::Exact, + extraction_profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + } +} + +/// One reference per physical artifact, spanning the first through the last +/// cited record. Callers can reopen exactly the bytes that produced the claim. +fn merged_evidence(facts: &[&DeploymentFact]) -> Vec { + let mut spans = BTreeMap::<&str, (u32, u32)>::new(); + for fact in facts { + let (Some(start), Some(end)) = (fact.reference.line_start, fact.reference.line_end) else { + continue; + }; + spans + .entry(fact.reference.artifact_id.as_str()) + .and_modify(|span| { + span.0 = span.0.min(start); + span.1 = span.1.max(end); + }) + .or_insert((start, end)); + } + + spans + .into_iter() + .map(|(artifact_id, (start, end))| SccmEvidenceRef { + artifact_id: artifact_id.to_owned(), + entry_id: format!("{artifact_id}:{start}-{end}"), + line_start: Some(start), + line_end: Some(end), + }) + .collect() +} + +fn first_fact<'a>( + facts: &[&'a DeploymentFact], + kind: DeploymentFactKind, +) -> Option<&'a DeploymentFact> { + facts.iter().copied().find(|fact| fact.kind == kind) +} + +/// The first record of `kind` that follows the record already on the chain. +/// +/// Every phase appends its record to a chain that `conclude` requires to be +/// strictly ordered, so a record elected without regard to the chain tail can +/// come from an earlier attempt and make an otherwise usable chain unorderable. +/// The fallback keeps presence detection identical: a kind that is present +/// still elects a record, and an unorderable one still fails closed later. +fn next_fact_after<'a>( + facts: &[&'a DeploymentFact], + kind: DeploymentFactKind, + earlier: Option<&DeploymentFact>, +) -> Option<&'a DeploymentFact> { + let Some(earlier) = earlier else { + return first_fact(facts, kind); + }; + facts + .iter() + .copied() + .find(|fact| fact.kind == kind && fact_is_strictly_before(earlier, fact)) + .or_else(|| first_fact(facts, kind)) +} + +/// The first record of `kind` that is strictly before every one of `later`. +/// +/// Facts are in canonical reference order, which is artifact-major and not +/// chronological, so the first record of a kind can belong to a different +/// attempt than the record it is cited beside. Electing the start against the +/// records it must precede keeps one attempt intact. Nothing is returned when +/// no start qualifies, so a genuinely unorderable set still refuses. +fn first_fact_before<'a>( + facts: &[&'a DeploymentFact], + kind: DeploymentFactKind, + later: &[&DeploymentFact], +) -> Option<&'a DeploymentFact> { + facts.iter().copied().find(|fact| { + fact.kind == kind + && later + .iter() + .all(|candidate| fact_is_strictly_before(fact, candidate)) + }) +} + +/// The first strictly ordered start and completion for one key. +/// +/// Taking the first record of each kind independently can straddle two attempts +/// and pair a later start with an earlier completion. That pair fails the chain +/// order check in `conclude` and downgrades a transaction whose evidence does +/// contain an orderable attempt. The pair is elected together instead. +fn ordered_pair<'a>( + facts: &[&'a DeploymentFact], + start_kind: DeploymentFactKind, + completion_kind: DeploymentFactKind, +) -> Option<(&'a DeploymentFact, &'a DeploymentFact)> { + facts + .iter() + .copied() + .filter(|fact| fact.kind == completion_kind) + .find_map(|completion| { + first_fact_before(facts, start_kind, &[completion]).map(|start| (start, completion)) + }) +} + +/// Every failure that no later success of the same phase can be ordered after. +/// +/// All of them are returned. Two records that are equally terminal for one key +/// are both evidence; picking one would let sort order decide what the caller +/// is shown. +fn unrecovered_failures<'a>( + facts: &[&'a DeploymentFact], + failure_kind: DeploymentFactKind, + success_kind: DeploymentFactKind, +) -> Vec<&'a DeploymentFact> { + facts + .iter() + .copied() + .filter(|fact| fact.kind == failure_kind) + .filter(|failure| { + !facts.iter().any(|candidate| { + candidate.kind == success_kind && fact_is_strictly_before(failure, candidate) + }) + }) + .collect() +} + +fn facts_of_kind<'a>( + facts: &[&'a DeploymentFact], + kind: DeploymentFactKind, +) -> Vec<&'a DeploymentFact> { + facts + .iter() + .copied() + .filter(|fact| fact.kind == kind) + .collect() +} + +/// The one cross-side output, and the only place a client key value leaves this +/// reducer. It republishes the transaction key that already survived the +/// ambiguity guard, and only when exactly one complete content record carries +/// it. Two differently keyed content records are a conflict, not a choice +/// between them, so nothing is published. +fn counterpart_ready_fact( + facts: &[&DeploymentFact], + key: &SccmDeploymentKey, +) -> Option { + let candidates = facts + .iter() + .copied() + .filter(|fact| { + matches!( + fact.kind, + DeploymentFactKind::ContentLocated | DeploymentFactKind::ContentRequested + ) + }) + .collect::>(); + // A repeated identical request is not ambiguity, but the citation still + // may not be chosen by artifact name: take the earliest record under the + // records' own order, and refuse when they cannot be ordered at all. + let fact = earliest_comparable_fact(&candidates)?; + + let package_id = key.package_id.clone()?; + let content_id = key.content_id.clone()?; + let content_version = key.content_version?; + let distribution_point_host_handle = key.distribution_point_host_handle.clone()?; + let request_id = key.request_id.clone()?; + if fact.package_id.as_deref() != Some(package_id.as_str()) + || fact.content_id.as_deref() != Some(content_id.as_str()) + || fact.content_version != Some(content_version) + || fact.distribution_point_host_handle.as_deref() + != Some(distribution_point_host_handle.as_str()) + || fact.request_id.as_deref() != Some(request_id.as_str()) + { + return None; + } + + let offset_minutes = fact.offset_minutes?; + if !fact.time_comparable { + return None; + } + + Some(SccmDeploymentCounterpartFact { + fact_kind: SccmDeploymentCounterpartFactKind::ClientContentRequest, + phase: SccmDeploymentPhase::LocateContent, + extraction_profile_id: SCCM_DEPLOYMENT_TEST_PROFILE_ID.to_owned(), + package_id, + content_id, + content_version, + distribution_point_host_handle, + request_id, + timestamp_provenance: SccmDeploymentTimestampProvenance { + kind: SccmDeploymentTimestampProvenanceKind::ExplicitOffset, + offset_minutes, + normalized_utc: format_normalized_utc(fact.utc_millis?)?, + }, + evidence: fact.reference.clone(), + }) +} + +/// The earliest of several records, or nothing when any pair of them cannot be +/// ordered. Refusing is the only answer that does not invent a sequence. +fn earliest_comparable_fact<'a>(facts: &[&'a DeploymentFact]) -> Option<&'a DeploymentFact> { + let mut earliest = *facts.first()?; + for candidate in &facts[1..] { + match compare_fact_order(earliest, candidate)? { + Ordering::Greater => earliest = candidate, + Ordering::Less | Ordering::Equal => {} + } + } + for candidate in facts { + compare_fact_order(earliest, candidate)?; + } + Some(earliest) +} + +fn format_normalized_utc(millis: i64) -> Option { + let timestamp = chrono::DateTime::::from_timestamp_millis(millis)?; + Some(if millis.rem_euclid(1_000) == 0 { + timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string() + } else { + timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() + }) +} + +fn resolve_outcome(facts: &[&DeploymentFact], coverage: &[SccmDeploymentCoverage]) -> Outcome { + let mut chain: Vec<&DeploymentFact> = Vec::new(); + let mut last: Option = None; + + if let Some(not_applicable) = first_fact(facts, DeploymentFactKind::IntentNotApplicable) { + return conclude( + &[not_applicable], + SccmDeploymentPhase::Intent, + SccmDeploymentState::NotTargeted, + SccmDeploymentClassification::NotTargeted, + None, + None, + &[], + ); + } + + let Some(intent) = first_fact(facts, DeploymentFactKind::IntentTargeted) else { + return insufficient(SccmDeploymentPhase::Intent, last, REASON_INTENT, coverage); + }; + chain.push(intent); + last = Some(SccmDeploymentPhase::Intent); + + let requirement_failures = facts_of_kind(facts, DeploymentFactKind::RequirementsFailed); + let dependency_failures = facts_of_kind(facts, DeploymentFactKind::DependencyFailed); + if !requirement_failures.is_empty() || !dependency_failures.is_empty() { + // A failed requirement gates the dependency check, so it names the + // cause when both are present; the citations stay per cause. + let (finding_id, terminals) = if requirement_failures.is_empty() { + (FINDING_DEPENDENCY_TERMINAL, dependency_failures) + } else { + (FINDING_REQUIREMENTS_TERMINAL, requirement_failures) + }; + return conclude( + &chain, + SccmDeploymentPhase::Requirements, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + Some(finding_id), + &terminals, + ); + } + let Some(requirements) = next_fact_after( + facts, + DeploymentFactKind::RequirementsSatisfied, + chain.last().copied(), + ) else { + return insufficient( + SccmDeploymentPhase::Requirements, + last, + REASON_REQUIREMENTS, + coverage, + ); + }; + chain.push(requirements); + last = Some(SccmDeploymentPhase::Requirements); + + let Some(located) = next_fact_after( + facts, + DeploymentFactKind::ContentLocated, + chain.last().copied(), + ) else { + let reason = if first_fact(facts, DeploymentFactKind::ContentRequested).is_some() { + REASON_LOCATION_RESPONSE_MISSING + } else { + match coverage_for_group(coverage, GROUP_CONTENT).map(|row| &row.state) { + Some(SccmCoverageState::Partial) => REASON_LOCATION_ROTATION, + Some(SccmCoverageState::AccessDenied) => REASON_LOCATION_ACCESS_DENIED, + _ => REASON_LOCATION_ABSENT, + } + }; + return insufficient(SccmDeploymentPhase::LocateContent, last, reason, coverage); + }; + chain.push(located); + last = Some(SccmDeploymentPhase::LocateContent); + + let transfer_failures = unrecovered_failures( + facts, + DeploymentFactKind::TransferFailed, + DeploymentFactKind::TransferCompleted, + ); + if !transfer_failures.is_empty() { + let mut failed_chain = chain.clone(); + failed_chain.extend( + first_fact_before( + facts, + DeploymentFactKind::TransferStarted, + &transfer_failures, + ) + .or_else(|| first_fact(facts, DeploymentFactKind::TransferStarted)), + ); + return conclude( + &failed_chain, + SccmDeploymentPhase::Transfer, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + Some(FINDING_TRANSFER_TERMINAL), + &transfer_failures, + ); + } + let started = first_fact(facts, DeploymentFactKind::TransferStarted); + let completed = first_fact(facts, DeploymentFactKind::TransferCompleted); + let (Some(started), Some(completed)) = (started, completed) else { + return insufficient( + SccmDeploymentPhase::Transfer, + last, + REASON_TRANSFER, + coverage, + ); + }; + // An orderable attempt is preferred; a set with none still cites the first + // of each kind so an unorderable transfer keeps failing closed. + let (started, completed) = ordered_pair( + facts, + DeploymentFactKind::TransferStarted, + DeploymentFactKind::TransferCompleted, + ) + .unwrap_or((started, completed)); + chain.push(started); + chain.push(completed); + last = Some(SccmDeploymentPhase::Transfer); + + let cache_failures = unrecovered_failures( + facts, + DeploymentFactKind::CacheFailed, + DeploymentFactKind::CacheCommitted, + ); + if !cache_failures.is_empty() { + return conclude( + &chain, + SccmDeploymentPhase::Cache, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + Some(FINDING_CACHE_TERMINAL), + &cache_failures, + ); + } + let Some(cached) = next_fact_after( + facts, + DeploymentFactKind::CacheCommitted, + chain.last().copied(), + ) else { + return insufficient(SccmDeploymentPhase::Cache, last, REASON_CACHE, coverage); + }; + chain.push(cached); + last = Some(SccmDeploymentPhase::Cache); + + let enforce_failures = unrecovered_failures( + facts, + DeploymentFactKind::EnforceFailed, + DeploymentFactKind::EnforceSucceeded, + ); + if !enforce_failures.is_empty() { + return conclude( + &chain, + SccmDeploymentPhase::Enforce, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + Some(FINDING_ENFORCE_TERMINAL), + &enforce_failures, + ); + } + let Some(enforced) = next_fact_after( + facts, + DeploymentFactKind::EnforceSucceeded, + chain.last().copied(), + ) else { + return insufficient(SccmDeploymentPhase::Enforce, last, REASON_ENFORCE, coverage); + }; + chain.push(enforced); + last = Some(SccmDeploymentPhase::Enforce); + + if let Some(mismatch) = next_fact_after( + facts, + DeploymentFactKind::DetectionMismatch, + chain.last().copied(), + ) { + let mut mismatch_chain = chain.clone(); + mismatch_chain.push(mismatch); + return conclude( + &mismatch_chain, + SccmDeploymentPhase::Detect, + SccmDeploymentState::DetectionMismatch, + SccmDeploymentClassification::Symptom, + last, + Some(FINDING_DETECTION_MISMATCH), + &[], + ); + } + let Some(detected) = + next_fact_after(facts, DeploymentFactKind::Detected, chain.last().copied()) + else { + return insufficient(SccmDeploymentPhase::Detect, last, REASON_DETECT, coverage); + }; + chain.push(detected); + last = Some(SccmDeploymentPhase::Detect); + + let report_failures = unrecovered_failures( + facts, + DeploymentFactKind::ReportFailed, + DeploymentFactKind::ReportSucceeded, + ); + if !report_failures.is_empty() { + return conclude( + &chain, + SccmDeploymentPhase::Report, + SccmDeploymentState::Failed, + SccmDeploymentClassification::ConfirmedFailure, + last, + Some(FINDING_REPORT_TERMINAL), + &report_failures, + ); + } + let Some(reported) = next_fact_after( + facts, + DeploymentFactKind::ReportSucceeded, + chain.last().copied(), + ) else { + return insufficient(SccmDeploymentPhase::Report, last, REASON_REPORT, coverage); + }; + chain.push(reported); + + conclude( + &chain, + SccmDeploymentPhase::Report, + SccmDeploymentState::Succeeded, + SccmDeploymentClassification::Success, + last, + None, + &[], + ) +} + +/// Terminal and success outcomes require a usable chronology through every +/// prerequisite phase. Without one the transaction becomes a low-confidence +/// symptom that names the missing ordering evidence. +#[allow(clippy::too_many_arguments)] +fn conclude( + chain: &[&DeploymentFact], + phase: SccmDeploymentPhase, + state: SccmDeploymentState, + classification: SccmDeploymentClassification, + last_successful_phase: Option, + finding_id: Option<&'static str>, + terminals: &[&DeploymentFact], +) -> Outcome { + // Every cited terminal record must be orderable through the prerequisite + // chain. One that is not would be published on the strength of another. + let terminals_are_ordered = terminals.iter().all(|terminal| { + let mut candidate = chain.to_vec(); + if !candidate.iter().any(|fact| std::ptr::eq(*fact, *terminal)) { + candidate.push(terminal); + } + chain_has_usable_order(&candidate) + }); + if !chain_has_usable_order(chain) || !terminals_are_ordered { + return Outcome { + phase, + state: SccmDeploymentState::InsufficientEvidence, + classification: SccmDeploymentClassification::Symptom, + confidence: SccmDeploymentConfidence::Low, + last_successful_phase, + next_artifact: Some(SccmDeploymentArtifactRequest { + logical_artifact_id: phase.artifact_group().to_owned(), + reason: REASON_CHRONOLOGY.to_owned(), + }), + coverage_gap_artifact_ids: Vec::new(), + finding_id: Some(FINDING_CHRONOLOGY_UNCERTAIN), + terminal_evidence: Vec::new(), + }; + } + + let (confidence, last_successful_phase) = match state { + SccmDeploymentState::Succeeded => (SccmDeploymentConfidence::High, Some(phase)), + SccmDeploymentState::DetectionMismatch => { + (SccmDeploymentConfidence::Medium, last_successful_phase) + } + _ => (SccmDeploymentConfidence::High, last_successful_phase), + }; + + Outcome { + phase, + state, + classification, + confidence, + last_successful_phase, + next_artifact: None, + coverage_gap_artifact_ids: Vec::new(), + finding_id, + terminal_evidence: terminals + .iter() + .map(|fact| fact.reference.clone()) + .collect(), + } +} + +fn insufficient( + phase: SccmDeploymentPhase, + last_successful_phase: Option, + reason: &str, + coverage: &[SccmDeploymentCoverage], +) -> Outcome { + let group = phase.artifact_group(); + Outcome { + phase, + state: SccmDeploymentState::InsufficientEvidence, + classification: SccmDeploymentClassification::InsufficientEvidence, + confidence: SccmDeploymentConfidence::Low, + last_successful_phase, + next_artifact: Some(SccmDeploymentArtifactRequest { + logical_artifact_id: group.to_owned(), + reason: reason.to_owned(), + }), + coverage_gap_artifact_ids: coverage_for_group(coverage, group) + .map(|row| row.artifact_ids.clone()) + .unwrap_or_default(), + finding_id: Some(coverage_gap_finding_id(phase)), + terminal_evidence: Vec::new(), + } +} + +// --------------------------------------------------------------------------- +// Source-local observations +// --------------------------------------------------------------------------- + +/// Every client artifact holding bytes that no admitted fact represents. +/// +/// This covers two shapes: an artifact that produced no admitted fact at all, +/// and an artifact whose complete records were admitted but which still holds +/// physical lines no record covers. The second shape is why the sweep keys on +/// record completeness rather than on whether the artifact contributed facts. +fn source_local_observations( + bundle: &SccmNormalizedBundle, + artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, + admitted_artifact_ids: &BTreeSet<&str>, +) -> Vec { + let mut evidence_by_artifact = BTreeMap::<&str, Vec<&SccmEvidence>>::new(); + for evidence in bundle + .evidence + .iter() + .filter(|evidence| evidence.role == SccmRole::Client) + { + let artifact_id = evidence.reference.artifact_id.as_str(); + if !artifacts_by_id.contains_key(artifact_id) || !valid_reference(&evidence.reference) { + continue; + } + evidence_by_artifact + .entry(artifact_id) + .or_default() + .push(evidence); + } + + evidence_by_artifact + .into_iter() + .filter_map(|(artifact_id, evidence)| { + let artifact = artifacts_by_id.get(artifact_id)?; + let fragments = evidence + .iter() + .copied() + .filter(|item| item.completeness == SccmRecordCompleteness::PhysicalFragment) + .collect::>(); + if fragments.is_empty() && admitted_artifact_ids.contains(artifact_id) { + return None; + } + + // Cite the fragments when there are any: they are what no fact + // represents. Otherwise the whole artifact went unrepresented. + let cited = if fragments.is_empty() { + &evidence + } else { + &fragments + }; + let start = cited + .iter() + .filter_map(|item| item.reference.line_start) + .min()?; + let end = cited + .iter() + .filter_map(|item| item.reference.line_end) + .max()?; + let key_confidence = if cited.iter().any(|item| has_candidate_key(&item.message)) { + SccmDeploymentObservationKeyConfidence::Candidate + } else { + SccmDeploymentObservationKeyConfidence::None + }; + let complete_logical_record = observation_is_complete(artifact, &fragments); + + Some(SccmDeploymentObservation { + observation_id: format!( + "{}:{artifact_id}", + if complete_logical_record { + "supplemental" + } else { + "fragment" + } + ), + artifact_id: artifact_id.to_owned(), + complete_logical_record, + key_confidence, + confidence_ceiling: SccmDeploymentConfidence::Low, + correlation_eligible: false, + reason: observation_reason(artifact, complete_logical_record).to_owned(), + evidence: SccmEvidenceRef { + artifact_id: artifact_id.to_owned(), + entry_id: format!("{artifact_id}:{start}-{end}"), + line_start: Some(start), + line_end: Some(end), + }, + }) + }) + .collect() +} + +/// Completeness of what the observation cites, decided by the record and by the +/// source's framing rather than by the artifact's coverage state. +/// +/// A source that frames CCM records has no complete unit smaller than a record, +/// so any fragment is incomplete. For an unframed text source the physical line +/// is itself the unit, and it is complete unless the collected bytes were cut +/// short. +fn observation_is_complete(artifact: &SccmArtifact, fragments: &[&SccmEvidence]) -> bool { + if fragments.is_empty() { + return true; + } + let catalog = classify_artifact_name(&artifact.display_name, SccmRole::Client); + !catalog.uses_ccm_records && artifact.coverage == SccmCoverageState::Captured +} + +fn observation_reason(artifact: &SccmArtifact, complete_logical_record: bool) -> &'static str { + if complete_logical_record { + return "unvalidated supplemental text cannot override an exact keyed client transaction"; + } + match (&artifact.coverage, &artifact.rotation) { + (SccmCoverageState::Partial, SccmRotation::Current) => { + "current-file fragment cannot complete the archived physical record" + } + (SccmCoverageState::Partial, SccmRotation::LoUnderscore) => { + "archived-file fragment cannot be joined across a physical rotation boundary" + } + (SccmCoverageState::Partial, _) => { + "a physical rotation fragment cannot form a logical record" + } + (SccmCoverageState::Capped, _) => { + "capped bytes do not form a logical record and cannot attach by time" + } + _ => "an unframed physical line is not a logical record and cannot attach by time", + } +} + +/// A fragment may still show something that looks like a key. Saying so is not +/// the same as trusting it: the observation stays capped at Low and unlinked. +fn has_candidate_key(message: &str) -> bool { + let Some(payload) = deployment_event_payload(message) else { + return false; + }; + field_value(payload, "assignmentId").is_some_and(valid_guid) + || field_value(payload, "ciId").is_some_and(valid_guid) + || field_value(payload, "contentId").is_some_and(valid_guid) + || field_value(payload, "requestId").is_some_and(valid_guid) + || field_value(payload, "bitsJobId").is_some_and(valid_guid) + || field_value(payload, "productCode").is_some_and(valid_guid) + || field_value(payload, "packageId").is_some_and(valid_package_id) + || field_value(payload, "distributionPointHostHandle").is_some_and(valid_safe_handle) + || field_value(payload, "contentVersion") + .and_then(parse_content_version) + .is_some() +} + +// --------------------------------------------------------------------------- +// Findings +// --------------------------------------------------------------------------- + +const FINDING_REQUIREMENTS_TERMINAL: &str = "deployment-requirements-terminal"; +const FINDING_DEPENDENCY_TERMINAL: &str = "deployment-dependency-terminal"; +const FINDING_TRANSFER_TERMINAL: &str = "deployment-transfer-terminal"; +const FINDING_CACHE_TERMINAL: &str = "deployment-cache-terminal"; +const FINDING_ENFORCE_TERMINAL: &str = "deployment-enforce-terminal"; +const FINDING_REPORT_TERMINAL: &str = "deployment-report-terminal"; +const FINDING_DETECTION_MISMATCH: &str = "deployment-detection-mismatch"; +const FINDING_CHRONOLOGY_UNCERTAIN: &str = "deployment-chronology-uncertain"; + +/// Most causes can only occur at one phase, so their identity is already +/// unique. An unusable chronology can occur at any of the eight, so its +/// identity carries the phase and two of them never merge. +fn emitted_finding_id(base_id: &str, phase: SccmDeploymentPhase) -> String { + if base_id == FINDING_CHRONOLOGY_UNCERTAIN { + return format!("{base_id}-{}", kebab_case(phase.as_str())); + } + base_id.to_owned() +} + +fn coverage_gap_finding_id(phase: SccmDeploymentPhase) -> &'static str { + match phase { + SccmDeploymentPhase::Intent => "deployment-intent-coverage-gap", + SccmDeploymentPhase::Requirements => "deployment-requirements-coverage-gap", + SccmDeploymentPhase::LocateContent => "deployment-location-coverage-gap", + SccmDeploymentPhase::Transfer => "deployment-transfer-coverage-gap", + SccmDeploymentPhase::Cache => "deployment-cache-coverage-gap", + SccmDeploymentPhase::Enforce => "deployment-enforce-coverage-gap", + SccmDeploymentPhase::Detect => "deployment-detect-coverage-gap", + SccmDeploymentPhase::Report => "deployment-report-coverage-gap", + } +} + +/// Titles and summaries never name a distribution point, a server, a download +/// cause, or a policy prerequisite: those claims belong to other issues. +fn finding_text(finding_id: &str) -> (&'static str, &'static str) { + match finding_id { + FINDING_REQUIREMENTS_TERMINAL => ( + "Client requirement evaluation recorded a terminal failure", + "A complete version-profiled requirement record ended this assignment before any content phase.", + ), + FINDING_DEPENDENCY_TERMINAL => ( + "Client dependency evaluation recorded a terminal failure", + "A complete version-profiled dependency record ended this assignment before any content phase.", + ), + FINDING_TRANSFER_TERMINAL => ( + "Client content transfer recorded a terminal failure", + "The same exact content key recorded a terminal transfer error after content was located.", + ), + FINDING_CACHE_TERMINAL => ( + "Client cache commit recorded a terminal failure", + "The same exact content key recorded a terminal cache error after the transfer completed.", + ), + FINDING_ENFORCE_TERMINAL => ( + "Client enforcement recorded a terminal failure", + "A complete enforcement record ended with a nonzero terminal exit code after the cache commit.", + ), + FINDING_REPORT_TERMINAL => ( + "Client deployment state report recorded a terminal failure", + "A complete state report ended this assignment with a failed deployment state.", + ), + FINDING_DETECTION_MISMATCH => ( + "Post-enforcement detection did not find the application", + "Enforcement completed with a zero exit code and detection still reported the application absent.", + ), + FINDING_CHRONOLOGY_UNCERTAIN => ( + "Deployment chronology is not usable", + "Records for this key cannot be ordered through the earlier phases, so no outcome is claimed.", + ), + "deployment-intent-coverage-gap" => ( + "Client application intent evidence is incomplete", + "No complete client intent record was available for this assignment and CI.", + ), + "deployment-requirements-coverage-gap" => ( + "Client requirement evidence is incomplete", + "No complete client requirement or dependency outcome was available for this assignment and CI.", + ), + "deployment-location-coverage-gap" => ( + "Client content-location evidence is incomplete", + "No complete client content-location record was available for this assignment and CI.", + ), + "deployment-transfer-coverage-gap" => ( + "Client content transfer evidence is incomplete", + "No complete client transfer outcome was available for this content key.", + ), + "deployment-cache-coverage-gap" => ( + "Client cache commit evidence is incomplete", + "No complete client cache commit outcome was available for this content key.", + ), + "deployment-enforce-coverage-gap" => ( + "Client enforcement evidence is incomplete", + "No complete client enforcement outcome was available for this content key.", + ), + "deployment-detect-coverage-gap" => ( + "Client detection evidence is incomplete", + "No complete client detection outcome was available for this content key.", + ), + _ => ( + "Client deployment state report evidence is incomplete", + "No complete client state report was available for this content key.", + ), + } +} + +/// The smallest catalog source that can close the gap for a phase. +fn phase_artifact_request(phase: SccmDeploymentPhase) -> SccmArtifactRequest { + let (logical_id, basename) = match phase { + SccmDeploymentPhase::Intent | SccmDeploymentPhase::Requirements => { + ("appIntentEval", "AppIntentEval.log") + } + SccmDeploymentPhase::LocateContent | SccmDeploymentPhase::Cache => ("cas", "CAS.log"), + SccmDeploymentPhase::Transfer => ("dataTransferService", "DataTransferService.log"), + SccmDeploymentPhase::Enforce => ("appEnforce", "AppEnforce.log"), + SccmDeploymentPhase::Detect => ("appDiscovery", "AppDiscovery.log"), + SccmDeploymentPhase::Report => ("stateMessage", "StateMessage.log"), + }; + SccmArtifactRequest { + logical_id: logical_id.to_owned(), + role: SccmRole::Client, + reason: format!("Collect the complete {basename} file."), + } +} + +/// Transactions blocked by the same cause share one finding: repeating an +/// identical coverage claim per assignment would overstate the evidence. +fn build_findings( + seeds: &[FindingSeed], + artifacts_by_id: &BTreeMap<&str, &SccmArtifact>, +) -> Vec { + let mut grouped = BTreeMap::<(&str, SccmDeploymentPhase), Vec<&FindingSeed>>::new(); + for seed in seeds { + grouped + .entry((seed.finding_id, seed.phase)) + .or_default() + .push(seed); + } + + grouped + .into_iter() + .filter_map(|((base_id, phase), seeds)| { + let first = seeds.first()?; + let (title, summary) = finding_text(base_id); + let finding_id = emitted_finding_id(base_id, phase); + // Every seed in this group shares the finding cause and the phase, + // so the representative can only speak for evidence it represents. + // The reported progress is still the least any of them reached. + let last_successful_phase = seeds + .iter() + .map(|seed| seed.last_successful_phase) + .min() + .unwrap_or(first.last_successful_phase); + + let evidence = if !first.terminal_evidence.is_empty() { + let mut references = seeds + .iter() + .flat_map(|seed| seed.evidence.iter().cloned()) + .collect::>(); + references.sort_by(compare_references); + references.dedup(); + references + } else { + merge_reference_spans(seeds.iter().flat_map(|seed| seed.evidence.iter())) + }; + + let mut terminal_evidence = seeds + .iter() + .flat_map(|seed| seed.terminal_evidence.iter().cloned()) + .map(SccmTerminalEvidence::observed_failure) + .collect::>(); + terminal_evidence + .sort_by(|left, right| compare_references(&left.reference, &right.reference)); + terminal_evidence.dedup(); + + let mut coverage_gaps = seeds + .iter() + .flat_map(|seed| { + seed.coverage_gap_artifact_ids + .iter() + .filter_map(|artifact_id| { + let artifact = artifacts_by_id.get(artifact_id.as_str())?; + Some(SccmFindingCoverageGap { + artifact_id: artifact_id.clone(), + role: SccmRole::Client, + coverage: artifact.coverage.clone(), + }) + }) + .collect::>() + }) + .collect::>(); + coverage_gaps.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id)); + coverage_gaps.dedup(); + if coverage_gaps.is_empty() { + // An insufficient-evidence finding always names what is + // missing. With no incomplete artifact to blame, the gap is the + // group itself: bytes exist, the needed record does not. + if let Some(group) = first.coverage_gap_group { + coverage_gaps.push(SccmFindingCoverageGap { + artifact_id: group.to_owned(), + role: SccmRole::Client, + coverage: SccmCoverageState::Partial, + }); + } + } + + let mut builder = SccmFindingBuilder::new(finding_id) + .class(first.class.clone()) + .phase(SccmPhase::Unknown(phase.as_str().to_owned())) + .role(SccmRole::Client) + .severity(match first.class { + SccmFindingClass::ConfirmedFailure => Severity::Error, + _ => Severity::Warning, + }) + .confidence(first.confidence) + .title(title) + .summary(summary) + .evidence(evidence) + .terminal_evidence(terminal_evidence) + .coverage_gaps(coverage_gaps); + if first.request_phase.is_some() { + builder = builder.next_artifact(phase_artifact_request(phase)); + } + + Some(SccmDeploymentFinding { + finding: builder + .build() + .expect("deployment finding must satisfy the shared contract"), + deployment_phase: phase, + last_successful_phase, + }) + }) + .collect() +} + +fn merge_reference_spans<'a>( + references: impl Iterator, +) -> Vec { + let mut spans = BTreeMap::::new(); + for reference in references { + let (Some(start), Some(end)) = (reference.line_start, reference.line_end) else { + continue; + }; + spans + .entry(reference.artifact_id.clone()) + .and_modify(|span| { + span.0 = span.0.min(start); + span.1 = span.1.max(end); + }) + .or_insert((start, end)); + } + + spans + .into_iter() + .map(|(artifact_id, (start, end))| SccmEvidenceRef { + entry_id: format!("{artifact_id}:{start}-{end}"), + artifact_id, + line_start: Some(start), + line_end: Some(end), + }) + .collect() +} diff --git a/crates/cmtraceopen-parser/src/sccm/client/mod.rs b/crates/cmtraceopen-parser/src/sccm/client/mod.rs new file mode 100644 index 000000000..8d6e18f60 --- /dev/null +++ b/crates/cmtraceopen-parser/src/sccm/client/mod.rs @@ -0,0 +1,15 @@ +mod deployment; + +pub use deployment::*; + +use super::{SccmArtifact, SccmEvidence}; + +/// Pure, normalized SCCM input shared by client workflow analyzers. +/// +/// The bundle owns no raw file handles or collection behavior. Its evidence has +/// already passed through the shared CCM logical-record scanner. +#[derive(Debug, Clone, PartialEq)] +pub struct SccmNormalizedBundle { + pub artifacts: Vec, + pub evidence: Vec, +} diff --git a/crates/cmtraceopen-parser/src/sccm/evidence.rs b/crates/cmtraceopen-parser/src/sccm/evidence.rs index 1a37dac9b..3c90dd052 100644 --- a/crates/cmtraceopen-parser/src/sccm/evidence.rs +++ b/crates/cmtraceopen-parser/src/sccm/evidence.rs @@ -3,7 +3,8 @@ use regex::Regex; use std::sync::OnceLock; use super::models::{ - SccmArtifact, SccmEvidence, SccmEvidenceRef, SccmRole, SccmTimeOrderingState, SccmTimestamp, + SccmArtifact, SccmEvidence, SccmEvidenceRef, SccmRecordCompleteness, SccmRole, + SccmTimeOrderingState, SccmTimestamp, }; const PUBLIC_MESSAGE_PROFILE: &str = "sccm-public-message-v1"; @@ -289,6 +290,7 @@ fn redact_email_identities(value: &str) -> String { #[derive(Debug, Clone, PartialEq)] pub(crate) struct SccmRawEvidenceSnapshot { evidence_id: String, + completeness: SccmRecordCompleteness, reference: SccmEvidenceRef, role: SccmRole, component: Option, @@ -311,6 +313,7 @@ impl SccmRawEvidenceSnapshot { Self { evidence_id: entry_id.clone(), + completeness: SccmRecordCompleteness::LogicalRecord, reference: SccmEvidenceRef { artifact_id: artifact.artifact_id.clone(), entry_id, @@ -326,9 +329,44 @@ impl SccmRawEvidenceSnapshot { } } + /// One physical line that never became a logical record. + /// + /// The snapshot carries no timestamp: a fragment has no provable instant, + /// so it can never be ordered against, or attached to, a real record. + pub(crate) fn from_physical_line( + artifact: &SccmArtifact, + line_number: u32, + text: &str, + ) -> Self { + let entry_id = format!("{}:{line_number}-{line_number}", artifact.artifact_id); + + Self { + evidence_id: entry_id.clone(), + completeness: SccmRecordCompleteness::PhysicalFragment, + reference: SccmEvidenceRef { + artifact_id: artifact.artifact_id.clone(), + entry_id, + line_start: Some(line_number), + line_end: Some(line_number), + }, + role: artifact.role.clone(), + component: None, + ccm_source_file: None, + message: text.to_owned(), + timestamp: SccmTimestamp { + original_display: None, + offset_minutes: None, + utc_millis: None, + ordering_state: SccmTimeOrderingState::TimestampMissing, + }, + raw_execution_context: None, + } + } + pub(crate) fn export(&self) -> SccmEvidence { SccmEvidence { evidence_id: self.evidence_id.clone(), + completeness: self.completeness, reference: self.reference.clone(), role: self.role.clone(), component: self.component.as_deref().map(project_public_text_v1), diff --git a/crates/cmtraceopen-parser/src/sccm/findings.rs b/crates/cmtraceopen-parser/src/sccm/findings.rs index 3e4fb3284..055a8c7ee 100644 --- a/crates/cmtraceopen-parser/src/sccm/findings.rs +++ b/crates/cmtraceopen-parser/src/sccm/findings.rs @@ -2262,12 +2262,13 @@ fn unknown_role_value(role: &SccmRole) -> &str { fn coverage_state_order(coverage: &SccmCoverageState) -> u8 { match coverage { SccmCoverageState::Captured => 0, - SccmCoverageState::Absent => 1, - SccmCoverageState::AccessDenied => 2, - SccmCoverageState::Capped => 3, - SccmCoverageState::Skipped => 4, - SccmCoverageState::Unsupported => 5, - SccmCoverageState::ParseFailed => 6, + SccmCoverageState::Partial => 1, + SccmCoverageState::Absent => 2, + SccmCoverageState::AccessDenied => 3, + SccmCoverageState::Capped => 4, + SccmCoverageState::Skipped => 5, + SccmCoverageState::Unsupported => 6, + SccmCoverageState::ParseFailed => 7, } } diff --git a/crates/cmtraceopen-parser/src/sccm/ingest.rs b/crates/cmtraceopen-parser/src/sccm/ingest.rs index 9c8654793..ee2b426d8 100644 --- a/crates/cmtraceopen-parser/src/sccm/ingest.rs +++ b/crates/cmtraceopen-parser/src/sccm/ingest.rs @@ -9,3 +9,45 @@ pub fn normalize_ccm_artifact(artifact: SccmArtifact, content: &str) -> Vec Vec { + let mut covered = scan_logical_records(content, &artifact.display_name) + .into_iter() + .map(|record| (record.line_start, record.line_end)) + .collect::>(); + // Sorting the spans lets both sequences be walked once. Rescanning every + // record for every line is quadratic, and a capped or rotated source is + // exactly the large input that leaves the most uncovered lines behind. + covered.sort_unstable(); + + // Records may nest or overlap, so the furthest end seen so far decides + // coverage. That is the same answer as asking whether any span contains the + // line, because every span starting at or before it has been folded in. + let mut next_span = 0usize; + let mut covered_through = 0u32; + content + .lines() + .enumerate() + .filter_map(|(index, line)| { + let line_number = u32::try_from(index + 1).ok()?; + while let Some((start, end)) = covered.get(next_span).copied() { + if start > line_number { + break; + } + covered_through = covered_through.max(end); + next_span += 1; + } + if covered_through >= line_number || line.trim().is_empty() { + return None; + } + Some(SccmRawEvidenceSnapshot::from_physical_line(artifact, line_number, line).export()) + }) + .collect() +} diff --git a/crates/cmtraceopen-parser/src/sccm/mod.rs b/crates/cmtraceopen-parser/src/sccm/mod.rs index 0535c8a06..88c9c975b 100644 --- a/crates/cmtraceopen-parser/src/sccm/mod.rs +++ b/crates/cmtraceopen-parser/src/sccm/mod.rs @@ -1,4 +1,5 @@ pub mod catalog; +pub mod client; mod evidence; mod findings; mod ingest; @@ -9,6 +10,7 @@ pub mod server; mod signals; pub use catalog::*; +pub use client::*; pub use findings::*; pub use ingest::*; pub use keys::*; diff --git a/crates/cmtraceopen-parser/src/sccm/models.rs b/crates/cmtraceopen-parser/src/sccm/models.rs index a87ba7802..f6473caa1 100644 --- a/crates/cmtraceopen-parser/src/sccm/models.rs +++ b/crates/cmtraceopen-parser/src/sccm/models.rs @@ -14,6 +14,8 @@ const INVALID_SCCM_ROLE_MESSAGE: &str = #[serde(rename_all = "camelCase")] pub enum SccmCoverageState { Captured, + /// Bytes were captured but they do not form a complete logical record. + Partial, Absent, AccessDenied, Capped, @@ -163,10 +165,23 @@ pub struct SccmSensitiveHandle { pub value: String, } +/// Whether a record framed completely, measured on the record itself. +/// +/// An artifact's coverage state describes the file that was collected; it can +/// never stand in for this. A complete file may still contain a physical line +/// that no logical record covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SccmRecordCompleteness { + LogicalRecord, + PhysicalFragment, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SccmEvidence { pub evidence_id: String, + pub completeness: SccmRecordCompleteness, pub reference: SccmEvidenceRef, pub role: SccmRole, pub component: Option, diff --git a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs index f5e4abdd6..33e1619db 100644 --- a/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs +++ b/crates/cmtraceopen-parser/src/sccm/server/windows/intake.rs @@ -1076,6 +1076,7 @@ fn role_sort_key(role: &SccmRole) -> &str { fn coverage_sort_key(state: &SccmCoverageState) -> &'static str { match state { SccmCoverageState::Captured => "captured", + SccmCoverageState::Partial => "partial", SccmCoverageState::Absent => "absent", SccmCoverageState::AccessDenied => "accessDenied", SccmCoverageState::Capped => "capped", diff --git a/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs new file mode 100644 index 000000000..24876a8eb --- /dev/null +++ b/crates/cmtraceopen-parser/tests/sccm_client_deployment.rs @@ -0,0 +1,2001 @@ +//! Behavior contract for the issue #322 client deployment/content reducer. +//! +//! Every expectation is read from the merged issue #322 fixture corpus under +//! `tests/fixtures/sccm/client/deployment`. The corpus is the specification: +//! this file only translates its declared manifests into a normalized bundle +//! and compares the reducer output against the declared expectations. + +use std::path::{Path, PathBuf}; + +use cmtraceopen_parser::sccm::{ + analyze_client_deployment, declared_source_catalog, normalize_ccm_artifact, + normalize_physical_lines, SccmArtifact, SccmConfidence, SccmCoverageState, + SccmDeploymentClassification, SccmDeploymentConfidence, SccmDeploymentKeyConfidence, + SccmDeploymentKeyProfileKind, SccmDeploymentObservationKeyConfidence, SccmDeploymentPhase, + SccmDeploymentProfileSelectionState, SccmDeploymentState, SccmEvidence, SccmFindingClass, + SccmNormalizedBundle, SccmRole, SccmRotation, SCCM_DEPLOYMENT_TEST_PROFILE_ID, +}; +use serde_json::Value; + +const SCENARIOS: [&str; 12] = [ + "bits-transfer-failure", + "cache-failure", + "dependency-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "incomplete", + "location-missing", + "not-targeted", + "requirements-failure", + "rotation-boundary", + "success", +]; + +fn deployment_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sccm/client/deployment") +} + +fn load_json(path: &Path) -> Value { + let contents = std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("{} is readable: {error}", path.display())); + serde_json::from_str(&contents) + .unwrap_or_else(|error| panic!("{} contains valid JSON: {error}", path.display())) +} + +fn expected(scenario: &str) -> Value { + load_json(&deployment_root().join(scenario).join("expected.json")) +} + +/// Translate one declared manifest artifact into the shared spine artifact. +/// +/// `captureState` plus `rotation.fragmentComplete` collapse into a single +/// coverage state: captured bytes that do not form a complete logical record +/// are `Partial`, never `Captured`. +fn artifact_from_manifest(entry: &Value) -> SccmArtifact { + let capture_state = entry["captureState"] + .as_str() + .expect("captureState is a string"); + let fragment_complete = entry["rotation"]["fragmentComplete"] + .as_bool() + .expect("fragmentComplete is a bool"); + let coverage = match capture_state { + "captured" if fragment_complete => SccmCoverageState::Captured, + "captured" => SccmCoverageState::Partial, + "capped" => SccmCoverageState::Capped, + "absent" => SccmCoverageState::Absent, + "accessDenied" => SccmCoverageState::AccessDenied, + "skipped" => SccmCoverageState::Skipped, + "unsupported" => SccmCoverageState::Unsupported, + other => panic!("unsupported captureState {other}"), + }; + let rotation = match entry["rotation"]["kind"].as_str() { + Some("current") => SccmRotation::Current, + Some("lo") => SccmRotation::LoUnderscore, + other => panic!("unsupported rotation kind {other:?}"), + }; + + SccmArtifact { + artifact_id: entry["artifactId"] + .as_str() + .expect("artifactId is a string") + .to_owned(), + display_name: entry["originalBasename"] + .as_str() + .expect("originalBasename is a string") + .to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: entry["sourceVersion"].as_str().map(str::to_owned), + collected_at_utc: entry["capturedUtc"].as_str().map(str::to_owned), + rotation, + coverage, + encoding: entry["encoding"].as_str().map(str::to_owned), + } +} + +fn load_bundle(scenario: &str) -> SccmNormalizedBundle { + let scenario_root = deployment_root().join(scenario); + let manifest = load_json(&scenario_root.join("manifest.json")); + let mut artifacts = Vec::new(); + let mut evidence: Vec = Vec::new(); + + for entry in manifest["artifacts"] + .as_array() + .expect("manifest artifacts are an array") + { + let artifact = artifact_from_manifest(entry); + if let Some(relative_path) = entry["relativePath"].as_str() { + let content = std::fs::read_to_string(scenario_root.join(relative_path)) + .expect("declared evidence is readable UTF-8"); + // Complete logical records first, then the physical-line residue an + // intake must still surface so a fragment is visible without ever + // becoming a fact. + evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); + evidence.extend(normalize_physical_lines(&artifact, &content)); + } + artifacts.push(artifact); + } + + SccmNormalizedBundle { + artifacts, + evidence, + } +} + +fn phase_name(phase: SccmDeploymentPhase) -> &'static str { + match phase { + SccmDeploymentPhase::Intent => "intent", + SccmDeploymentPhase::Requirements => "requirements", + SccmDeploymentPhase::LocateContent => "locateContent", + SccmDeploymentPhase::Transfer => "transfer", + SccmDeploymentPhase::Cache => "cache", + SccmDeploymentPhase::Enforce => "enforce", + SccmDeploymentPhase::Detect => "detect", + SccmDeploymentPhase::Report => "report", + } +} + +fn state_name(state: SccmDeploymentState) -> &'static str { + match state { + SccmDeploymentState::NotTargeted => "notTargeted", + SccmDeploymentState::InsufficientEvidence => "insufficientEvidence", + SccmDeploymentState::Failed => "failed", + SccmDeploymentState::DetectionMismatch => "detectionMismatch", + SccmDeploymentState::Succeeded => "succeeded", + } +} + +fn classification_name(classification: SccmDeploymentClassification) -> &'static str { + match classification { + SccmDeploymentClassification::NotTargeted => "notTargeted", + SccmDeploymentClassification::InsufficientEvidence => "insufficientEvidence", + SccmDeploymentClassification::Symptom => "symptom", + SccmDeploymentClassification::ConfirmedFailure => "confirmedFailure", + SccmDeploymentClassification::Success => "success", + } +} + +fn confidence_name(confidence: SccmDeploymentConfidence) -> &'static str { + match confidence { + SccmDeploymentConfidence::Low => "low", + SccmDeploymentConfidence::Medium => "medium", + SccmDeploymentConfidence::High => "high", + } +} + +fn key_profile_name(kind: SccmDeploymentKeyProfileKind) -> &'static str { + match kind { + SccmDeploymentKeyProfileKind::AssignmentCi => "assignmentCi", + SccmDeploymentKeyProfileKind::AssignmentCiContentTopology => "assignmentCiContentTopology", + } +} + +fn key_confidence_name(confidence: SccmDeploymentKeyConfidence) -> &'static str { + match confidence { + SccmDeploymentKeyConfidence::Candidate => "candidate", + SccmDeploymentKeyConfidence::Exact => "exact", + } +} + +#[test] +fn declared_transaction_outcomes_are_reproduced_for_every_scenario() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + assert_eq!( + analysis.transactions.len(), + declared.len(), + "{scenario}: transaction count" + ); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + assert_eq!( + produced.transaction_id, declared["transactionId"], + "{label}: transaction id" + ); + assert_eq!( + phase_name(produced.phase), + declared["phase"].as_str().expect("declared phase"), + "{label}: phase" + ); + assert_eq!( + state_name(produced.state), + declared["state"].as_str().expect("declared state"), + "{label}: state" + ); + assert_eq!( + produced.last_successful_phase.map(phase_name), + declared["lastSuccessfulPhase"].as_str(), + "{label}: last successful phase" + ); + assert_eq!( + classification_name(produced.classification), + declared["classification"] + .as_str() + .expect("declared classification"), + "{label}: classification" + ); + assert_eq!( + confidence_name(produced.confidence), + declared["confidence"] + .as_str() + .expect("declared confidence"), + "{label}: confidence" + ); + assert_eq!( + confidence_name(produced.confidence_ceiling), + declared["confidenceCeiling"] + .as_str() + .expect("declared confidence ceiling"), + "{label}: confidence ceiling" + ); + } + } +} + +#[test] +fn declared_transaction_keys_are_bound_to_the_selected_version_profile() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let key = &produced.key; + let declared_key = &declared["key"]; + + assert_eq!( + key_profile_name(key.key_profile_kind), + declared_key["keyProfileKind"] + .as_str() + .expect("declared key profile kind"), + "{label}: key profile kind" + ); + assert_eq!( + key_confidence_name(key.confidence), + declared_key["confidence"] + .as_str() + .expect("declared key confidence"), + "{label}: key confidence" + ); + assert_eq!( + key.extraction_profile_id, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + "{label}: extraction profile" + ); + assert_eq!( + declared_key["extractionProfileId"], SCCM_DEPLOYMENT_TEST_PROFILE_ID, + "{label}: declared extraction profile" + ); + + assert_eq!( + Some(key.assignment_id.as_str()), + declared_key["assignmentId"].as_str(), + "{label}: assignmentId" + ); + assert_eq!( + Some(key.ci_id.as_str()), + declared_key["ciId"].as_str(), + "{label}: ciId" + ); + assert_eq!( + key.package_id.as_deref(), + declared_key["packageId"].as_str(), + "{label}: packageId" + ); + assert_eq!( + key.content_id.as_deref(), + declared_key["contentId"].as_str(), + "{label}: contentId" + ); + assert_eq!( + key.content_version.map(u64::from), + declared_key["contentVersion"].as_u64(), + "{label}: contentVersion" + ); + assert_eq!( + key.distribution_point_host_handle.as_deref(), + declared_key["distributionPointHostHandle"].as_str(), + "{label}: distributionPointHostHandle" + ); + assert_eq!( + key.request_id.as_deref(), + declared_key["requestId"].as_str(), + "{label}: requestId" + ); + assert_eq!( + key.bits_job_id.as_deref(), + declared_key["bitsJobId"].as_str(), + "{label}: bitsJobId" + ); + assert_eq!( + key.product_code.as_deref(), + declared_key["productCode"].as_str(), + "{label}: productCode" + ); + assert_eq!( + key.exit_code.as_deref(), + declared_key["exitCode"].as_str(), + "{label}: exitCode" + ); + } + } +} + +#[test] +fn declared_transaction_evidence_spans_are_reproduced_exactly() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let produced_spans = produced + .evidence + .iter() + .map(|reference| { + ( + reference.artifact_id.clone(), + reference.line_start, + reference.line_end, + ) + }) + .collect::>(); + let declared_spans = declared["evidence"] + .as_array() + .expect("declared evidence is an array") + .iter() + .map(|reference| { + ( + reference["artifactId"] + .as_str() + .expect("declared artifactId") + .to_owned(), + reference["startLine"].as_u64().map(|line| line as u32), + reference["endLine"].as_u64().map(|line| line as u32), + ) + }) + .collect::>(); + assert_eq!(produced_spans, declared_spans, "{label}: evidence spans"); + } + } +} + +#[test] +fn counterpart_ready_facts_match_the_declared_content_request_boundary() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let declared_fact = &declared["counterpartReadyFact"]; + let Some(fact) = produced.counterpart_ready_fact.as_ref() else { + assert!( + declared_fact.is_null(), + "{label}: missing declared counterpart-ready fact" + ); + continue; + }; + assert!( + !declared_fact.is_null(), + "{label}: unexpected counterpart-ready fact" + ); + + assert_eq!( + phase_name(fact.phase), + declared_fact["phase"] + .as_str() + .expect("declared fact phase"), + "{label}: counterpart phase" + ); + assert_eq!( + fact.extraction_profile_id, SCCM_DEPLOYMENT_TEST_PROFILE_ID, + "{label}: counterpart profile" + ); + assert_eq!( + Some(fact.package_id.as_str()), + declared_fact["packageId"].as_str(), + "{label}: counterpart packageId" + ); + assert_eq!( + Some(fact.content_id.as_str()), + declared_fact["contentId"].as_str(), + "{label}: counterpart contentId" + ); + assert_eq!( + Some(u64::from(fact.content_version)), + declared_fact["contentVersion"].as_u64(), + "{label}: counterpart contentVersion" + ); + assert_eq!( + Some(fact.distribution_point_host_handle.as_str()), + declared_fact["distributionPointHostHandle"].as_str(), + "{label}: counterpart distributionPointHostHandle" + ); + assert_eq!( + Some(fact.request_id.as_str()), + declared_fact["requestId"].as_str(), + "{label}: counterpart requestId" + ); + assert_eq!( + Some(fact.timestamp_provenance.normalized_utc.as_str()), + declared_fact["timestampProvenance"]["normalizedUtc"].as_str(), + "{label}: counterpart normalized UTC" + ); + assert_eq!( + Some(i64::from(fact.timestamp_provenance.offset_minutes)), + declared_fact["timestampProvenance"]["offsetMinutes"].as_i64(), + "{label}: counterpart offset" + ); + assert_eq!( + Some(fact.evidence.artifact_id.as_str()), + declared_fact["evidence"]["artifactId"].as_str(), + "{label}: counterpart evidence artifact" + ); + assert_eq!( + fact.evidence.line_start.map(u64::from), + declared_fact["evidence"]["startLine"].as_u64(), + "{label}: counterpart evidence start" + ); + assert_eq!( + fact.evidence.line_end.map(u64::from), + declared_fact["evidence"]["endLine"].as_u64(), + "{label}: counterpart evidence end" + ); + } + } +} + +#[test] +fn no_scenario_claims_a_distribution_point_or_server_cause() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let handoff = &analysis.correlation_handoff; + assert!(!handoff.performed, "{scenario}: #333 is not performed here"); + assert!( + !handoff.time_only_eligible, + "{scenario}: time alone cannot correlate" + ); + assert!( + !handoff.topology_compatibility_evaluated, + "{scenario}: topology belongs to #333" + ); + assert!( + !handoff.server_cause_claimed, + "{scenario}: no DP or server cause" + ); + assert_eq!( + handoff.emitted_counterpart_ready_fact, + analysis + .transactions + .iter() + .any(|transaction| transaction.counterpart_ready_fact.is_some()), + "{scenario}: counterpart handoff flag" + ); + } +} + +fn coverage_state_name(state: &SccmCoverageState) -> &'static str { + match state { + SccmCoverageState::Captured => "captured", + SccmCoverageState::Partial => "partial", + SccmCoverageState::Absent => "absent", + SccmCoverageState::AccessDenied => "accessDenied", + SccmCoverageState::Capped => "capped", + SccmCoverageState::Skipped => "skipped", + SccmCoverageState::Unsupported => "unsupported", + SccmCoverageState::ParseFailed => "parseFailed", + } +} + +fn finding_class_name(class: &SccmFindingClass) -> &'static str { + match class { + SccmFindingClass::Symptom => "symptom", + SccmFindingClass::ConfirmedFailure => "confirmedFailure", + SccmFindingClass::BlockedOrDeferred => "blockedOrDeferred", + SccmFindingClass::LikelyContributor => "likelyContributor", + SccmFindingClass::InsufficientEvidence => "insufficientEvidence", + } +} + +fn shared_confidence_name(confidence: &SccmConfidence) -> &'static str { + match confidence { + SccmConfidence::None => "none", + SccmConfidence::Low => "low", + SccmConfidence::Moderate => "moderate", + SccmConfidence::High => "high", + } +} + +fn observation_key_confidence_name( + confidence: SccmDeploymentObservationKeyConfidence, +) -> &'static str { + match confidence { + SccmDeploymentObservationKeyConfidence::None => "none", + SccmDeploymentObservationKeyConfidence::Candidate => "candidate", + } +} + +fn declared_evidence_spans(value: &Value) -> Vec<(String, Option, Option)> { + value + .as_array() + .expect("declared evidence is an array") + .iter() + .map(|reference| { + ( + reference["artifactId"] + .as_str() + .expect("declared artifactId") + .to_owned(), + reference["startLine"].as_u64().map(|line| line as u32), + reference["endLine"].as_u64().map(|line| line as u32), + ) + }) + .collect() +} + +#[test] +fn declared_group_coverage_is_reproduced_for_every_scenario() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["coverage"] + .as_array() + .expect("declared coverage is an array"); + + let produced = analysis + .coverage + .iter() + .map(|row| { + ( + row.logical_artifact_id.clone(), + coverage_state_name(&row.state), + ) + }) + .collect::>(); + let declared_rows = declared + .iter() + .map(|row| { + ( + row["logicalArtifactId"] + .as_str() + .expect("declared logicalArtifactId") + .to_owned(), + row["state"].as_str().expect("declared coverage state"), + ) + }) + .collect::>(); + assert_eq!(produced, declared_rows, "{scenario}: coverage rows"); + + for row in declared { + let Some(declared_ids) = row["artifactIds"].as_array() else { + continue; + }; + let logical_artifact_id = row["logicalArtifactId"] + .as_str() + .expect("declared logicalArtifactId"); + let produced_ids = analysis + .coverage + .iter() + .find(|produced| produced.logical_artifact_id == logical_artifact_id) + .map(|produced| produced.artifact_ids.clone()) + .expect("coverage row exists"); + let declared_ids = declared_ids + .iter() + .map(|id| id.as_str().expect("declared artifact id").to_owned()) + .collect::>(); + assert_eq!( + produced_ids, declared_ids, + "{scenario}/{logical_artifact_id}: partial coverage artifact ids" + ); + } + } +} + +#[test] +fn declared_next_artifacts_and_coverage_gaps_are_reproduced() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["transactions"] + .as_array() + .expect("declared transactions are an array"); + + for (produced, declared) in analysis.transactions.iter().zip(declared) { + let label = format!("{scenario}/{}", produced.transaction_id); + let declared_ids = declared["coverageGapArtifactIds"] + .as_array() + .expect("declared coverage gap ids") + .iter() + .map(|id| id.as_str().expect("declared gap id").to_owned()) + .collect::>(); + assert_eq!( + produced.coverage_gap_artifact_ids, declared_ids, + "{label}: coverage gap artifact ids" + ); + + match produced.next_artifact.as_ref() { + Some(request) => { + assert_eq!( + Some(request.logical_artifact_id.as_str()), + declared["nextArtifact"]["logicalArtifactId"].as_str(), + "{label}: next artifact group" + ); + assert_eq!( + Some(request.reason.as_str()), + declared["nextArtifact"]["reason"].as_str(), + "{label}: next artifact reason" + ); + } + None => assert!( + declared["nextArtifact"].is_null(), + "{label}: unexpected next artifact" + ), + } + } + } +} + +#[test] +fn declared_source_local_observations_stay_low_and_uncorrelatable() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = expected["sourceLocalObservations"] + .as_array() + .expect("declared observations are an array"); + + assert_eq!( + analysis.source_local_observations.len(), + declared.len(), + "{scenario}: source-local observation count" + ); + + for declared in declared { + let artifact_id = declared["artifactId"] + .as_str() + .expect("declared observation artifact"); + let produced = analysis + .source_local_observations + .iter() + .find(|observation| observation.artifact_id == artifact_id) + .unwrap_or_else(|| panic!("{scenario}: no observation for {artifact_id}")); + let label = format!("{scenario}/{artifact_id}"); + + assert_eq!( + produced.complete_logical_record, + declared["completeLogicalRecord"] + .as_bool() + .expect("declared completeLogicalRecord"), + "{label}: complete logical record" + ); + assert_eq!( + observation_key_confidence_name(produced.key_confidence), + declared["keyConfidence"] + .as_str() + .expect("declared keyConfidence"), + "{label}: key confidence" + ); + assert_eq!( + confidence_name(produced.confidence_ceiling), + declared["confidenceCeiling"] + .as_str() + .expect("declared confidenceCeiling"), + "{label}: confidence ceiling" + ); + assert_eq!( + produced.correlation_eligible, + declared["correlationEligible"] + .as_bool() + .expect("declared correlationEligible"), + "{label}: correlation eligibility" + ); + assert_eq!( + ( + produced.evidence.artifact_id.clone(), + produced.evidence.line_start, + produced.evidence.line_end, + ), + ( + declared["evidence"]["artifactId"] + .as_str() + .expect("declared observation evidence artifact") + .to_owned(), + declared["evidence"]["startLine"] + .as_u64() + .map(|line| line as u32), + declared["evidence"]["endLine"] + .as_u64() + .map(|line| line as u32), + ), + "{label}: observation evidence" + ); + } + } +} + +#[test] +fn declared_findings_are_produced_and_respect_their_prohibited_claims() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + + for declared in expected["findings"] + .as_array() + .expect("declared findings are an array") + { + let finding_id = declared["findingId"].as_str().expect("declared findingId"); + let produced = analysis + .findings + .iter() + .find(|finding| finding.finding.finding_id == finding_id) + .unwrap_or_else(|| panic!("{scenario}: no finding {finding_id}")); + let label = format!("{scenario}/{finding_id}"); + + assert_eq!( + finding_class_name(&produced.finding.class), + declared["class"].as_str().expect("declared class"), + "{label}: class" + ); + assert_eq!( + phase_name(produced.deployment_phase), + declared["phase"].as_str().expect("declared phase"), + "{label}: phase" + ); + assert_eq!(produced.finding.role, SccmRole::Client, "{label}: role"); + assert_eq!( + declared["role"].as_str(), + Some("client"), + "{label}: declared role" + ); + assert_eq!( + shared_confidence_name(&produced.finding.confidence), + declared["confidence"] + .as_str() + .expect("declared confidence"), + "{label}: confidence" + ); + + let produced_spans = produced + .finding + .evidence + .iter() + .map(|reference| { + ( + reference.artifact_id.clone(), + reference.line_start, + reference.line_end, + ) + }) + .collect::>(); + assert_eq!( + produced_spans, + declared_evidence_spans(&declared["evidence"]), + "{label}: finding evidence" + ); + + let produced_gaps = produced + .finding + .coverage_gaps + .iter() + .map(|gap| gap.artifact_id.clone()) + .collect::>(); + let declared_gaps = declared["coverageGapArtifactIds"] + .as_array() + .expect("declared finding coverage gaps") + .iter() + .map(|id| id.as_str().expect("declared gap id").to_owned()) + .collect::>(); + assert_eq!( + produced_gaps, declared_gaps, + "{label}: finding coverage gaps" + ); + + let claim_text = format!( + "{} {}", + produced.finding.title.to_ascii_lowercase(), + produced.finding.summary.to_ascii_lowercase() + ); + for prohibited in declared["mustNotClaim"] + .as_array() + .expect("declared prohibited claims") + { + let prohibited = prohibited + .as_str() + .expect("declared prohibited claim") + .to_ascii_lowercase(); + assert!( + !claim_text.contains(&prohibited), + "{label}: finding claims {prohibited}" + ); + } + } + } +} + +#[test] +fn every_finding_satisfies_the_shared_finding_contract() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let catalog = declared_source_catalog(); + + for finding in &analysis.findings { + finding.finding.validate().unwrap_or_else(|error| { + panic!( + "{scenario}/{}: shared finding contract: {error:?}", + finding.finding.finding_id + ) + }); + for request in &finding.finding.next_artifacts { + assert!( + catalog + .iter() + .any(|entry| entry.logical_name == request.logical_id + && entry.role == request.role), + "{scenario}/{}: undeclared next artifact {}", + finding.finding.finding_id, + request.logical_id + ); + } + } + + for request in analysis + .findings + .iter() + .flat_map(|finding| finding.finding.next_artifacts.iter().cloned()) + { + assert!( + analysis.artifact_requests.contains(&request), + "{scenario}: aggregated artifact requests omit {}", + request.logical_id + ); + } + for gap in analysis + .findings + .iter() + .flat_map(|finding| finding.finding.coverage_gaps.iter().cloned()) + { + assert!( + analysis.coverage_gaps.contains(&gap), + "{scenario}: aggregated coverage gaps omit {}", + gap.artifact_id + ); + } + } +} + +#[test] +fn reordering_the_bundle_never_changes_the_analysis() { + for scenario in SCENARIOS { + let bundle = load_bundle(scenario); + let forward = analyze_client_deployment(&bundle); + + let reversed = SccmNormalizedBundle { + artifacts: bundle.artifacts.iter().rev().cloned().collect(), + evidence: bundle.evidence.iter().rev().cloned().collect(), + }; + assert_eq!( + analyze_client_deployment(&reversed), + forward, + "{scenario}: reordered input changed the analysis" + ); + } +} + +// --------------------------------------------------------------------------- +// Synthetic adversarial contracts +// +// These cases cannot exist in the merged corpus but are exactly the ways a +// deployment reducer overstates evidence in the field. +// --------------------------------------------------------------------------- + +const ASSIGNMENT: &str = "10000000-0000-0000-0000-0000000000a1"; +const CI: &str = "20000000-0000-0000-0000-0000000000a2"; +const CONTENT: &str = "30000000-0000-0000-0000-0000000000a3"; +const REQUEST: &str = "40000000-0000-0000-0000-0000000000a4"; +const BITS_JOB: &str = "50000000-0000-0000-0000-0000000000a5"; +const PRODUCT: &str = "60000000-0000-0000-0000-0000000000a6"; +const OTHER_CI: &str = "20000000-0000-0000-0000-0000000000b2"; + +fn client_artifact(artifact_id: &str, basename: &str) -> SccmArtifact { + SccmArtifact { + artifact_id: artifact_id.to_owned(), + display_name: basename.to_owned(), + original_path: None, + host: None, + role: SccmRole::Client, + configmgr_version: Some("5.00.TEST.0000".to_owned()), + collected_at_utc: None, + rotation: SccmRotation::Current, + coverage: SccmCoverageState::Captured, + encoding: Some("utf-8".to_owned()), + } +} + +fn record(message: &str, time: &str, component: &str) -> String { + format!( + "\n" + ) +} + +fn bundle_from(sources: Vec<(SccmArtifact, String)>) -> SccmNormalizedBundle { + let mut artifacts = Vec::new(); + let mut evidence = Vec::new(); + for (artifact, content) in sources { + evidence.extend(normalize_ccm_artifact(artifact.clone(), &content)); + evidence.extend(normalize_physical_lines(&artifact, &content)); + artifacts.push(artifact); + } + SccmNormalizedBundle { + artifacts, + evidence, + } +} + +fn intent_content() -> String { + format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ), + record( + &format!("Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}"), + "05:00:01.000+000", + "AppIntentEval", + ), + ) +} + +fn content_content() -> String { + format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId=LAB00021 contentId={CONTENT} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={REQUEST} siteCode=LAB" + ), + "05:00:02.000+000", + "CAS", + ), + record( + &format!( + "Cache commit completed assignmentId={ASSIGNMENT} ciId={CI} contentId={CONTENT} contentVersion=21" + ), + "05:00:05.000+000", + "CAS", + ), + ) +} + +fn transfer_content(started_time: &str, completed_time: &str) -> String { + format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment transfer started assignmentId={ASSIGNMENT} contentId={CONTENT} contentVersion=21 requestId={REQUEST} bitsJobId={BITS_JOB}" + ), + started_time, + "DataTransferService", + ), + record( + &format!("Transfer completed assignmentId={ASSIGNMENT} contentId={CONTENT} bitsJobId={BITS_JOB}"), + completed_time, + "DataTransferService", + ), + ) +} + +fn only_transaction( + analysis: &cmtraceopen_parser::sccm::SccmDeploymentAnalysis, +) -> &cmtraceopen_parser::sccm::SccmDeploymentTransaction { + assert_eq!(analysis.transactions.len(), 1, "expected one transaction"); + &analysis.transactions[0] +} + +#[test] +fn a_duplicate_client_artifact_identity_reports_coverage_only() { + let artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); + let mut bundle = bundle_from(vec![(artifact.clone(), intent_content())]); + assert_eq!( + analyze_client_deployment(&bundle).transactions.len(), + 1, + "the same bundle without a collision must produce a transaction" + ); + + bundle.artifacts.push(artifact); + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "an ambiguous artifact identity cannot elect a source" + ); + assert!(analysis.findings.is_empty()); + assert!( + !analysis.coverage.is_empty(), + "coverage still reports what was collected" + ); +} + +#[test] +fn a_duplicate_evidence_identity_reports_coverage_only() { + let artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); + let mut bundle = bundle_from(vec![(artifact, intent_content())]); + bundle.evidence.extend(bundle.evidence.clone()); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "a duplicated logical record cannot be an authority" + ); + assert!(analysis.findings.is_empty()); +} + +#[test] +fn an_artifact_from_another_role_never_decides_a_client_source() { + let client = client_artifact("shared-identity", "AppIntentEval.log"); + let mut management_point = client.clone(); + management_point.role = SccmRole::ManagementPoint; + management_point.display_name = "mpcontrol.log".to_owned(); + + let mut forward = bundle_from(vec![(client.clone(), intent_content())]); + let mut reversed = forward.clone(); + forward.artifacts.push(management_point.clone()); + reversed.artifacts.insert(0, management_point); + + for (label, bundle) in [("client first", forward), ("client last", reversed)] { + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!( + transaction.key.assignment_id, ASSIGNMENT, + "{label}: client source was displaced by another role" + ); + assert_eq!( + phase_name(transaction.phase), + "locateContent", + "{label}: phase" + ); + } +} + +#[test] +fn an_unorderable_terminal_record_downgrades_to_a_low_confidence_symptom() { + let failing_transfer = format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment transfer started assignmentId={ASSIGNMENT} contentId={CONTENT} contentVersion=21 requestId={REQUEST} bitsJobId={BITS_JOB}" + ), + "05:00:03.000", + "DataTransferService", + ), + record( + &format!( + "Transfer terminal failure assignmentId={ASSIGNMENT} contentId={CONTENT} bitsJobId={BITS_JOB} errorCode=0x80070020 terminal=true" + ), + "05:00:04.000", + "DataTransferService", + ), + ); + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + failing_transfer, + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(phase_name(transaction.phase), "transfer"); + assert_eq!(state_name(transaction.state), "insufficientEvidence"); + assert_eq!(classification_name(transaction.classification), "symptom"); + assert_eq!(confidence_name(transaction.confidence), "low"); + assert!( + analysis.findings.iter().any(|finding| finding + .finding + .finding_id + .starts_with("deployment-chronology-uncertain")), + "an unorderable chain must name the missing ordering evidence" + ); +} + +#[test] +fn a_transfer_pair_is_elected_together_across_two_attempts() { + // A rotated transfer source keeps the completion of an attempt whose start + // has already scrolled out, so the earliest completion in canonical + // reference order belongs to a different attempt than the earliest start. + let orphan_completion = record( + &format!( + "Transfer completed assignmentId={ASSIGNMENT} contentId={CONTENT} bitsJobId={BITS_JOB}" + ), + "05:00:03.000+000", + "ContentTransferManager", + ); + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer-a", "ContentTransferManager.log"), + orphan_completion, + ), + ( + client_artifact("synthetic-transfer-b", "DataTransferService.log"), + transfer_content("05:00:03.500+000", "05:00:04.000+000"), + ), + ( + client_artifact("synthetic-enforce", "AppEnforce.log"), + record( + &format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603 terminal=true" + ), + "05:00:06.000+000", + "AppEnforce", + ), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!( + transaction.last_successful_phase.map(phase_name), + Some("cache"), + "one attempt is fully ordered, so the transfer phase must be admitted" + ); + assert_eq!(phase_name(transaction.phase), "enforce"); + assert_eq!( + state_name(transaction.state), + "failed", + "a terminal enforcement record stays terminal when the chain is orderable" + ); + assert_eq!( + classification_name(transaction.classification), + "confirmedFailure" + ); + assert!( + !analysis.findings.iter().any(|finding| finding + .finding + .finding_id + .starts_with("deployment-chronology-uncertain")), + "an orderable start and completion must not be reported as unorderable" + ); +} + +#[test] +fn a_label_embedded_in_a_longer_phrase_is_not_a_requirements_outcome() { + let embedded = format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ), + record( + &format!("Base requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}"), + "05:00:01.000+000", + "AppIntentEval", + ), + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + embedded, + )]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(phase_name(transaction.phase), "requirements"); + assert_eq!(state_name(transaction.state), "insufficientEvidence"); + assert_eq!( + transaction.last_successful_phase.map(phase_name), + Some("intent") + ); +} + +#[test] +fn a_duplicated_key_label_fails_closed_for_the_whole_record() { + let duplicated = record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + duplicated, + )]); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "a duplicated exact-token label cannot pick a value" + ); +} + +#[test] +fn two_configuration_items_under_one_assignment_never_form_a_transaction() { + let ambiguous = format!( + "{}{}", + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ), + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={OTHER_CI} state=targeted" + ), + "05:00:01.000+000", + "AppIntentEval", + ), + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + ambiguous, + )]); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "two configuration items cannot share one exact transaction key" + ); +} + +#[test] +fn an_unprofiled_source_version_stays_a_source_local_observation() { + let mut artifact = client_artifact("synthetic-intent", "AppIntentEval.log"); + artifact.configmgr_version = Some("5.00.PROD.9128".to_owned()); + let bundle = bundle_from(vec![(artifact, intent_content())]); + + let analysis = analyze_client_deployment(&bundle); + assert!( + analysis.transactions.is_empty(), + "an unprofiled version cannot produce facts" + ); + assert_eq!(analysis.source_local_observations.len(), 1); + let observation = &analysis.source_local_observations[0]; + assert!(observation.complete_logical_record); + assert!(!observation.correlation_eligible); + assert_eq!( + confidence_name(observation.confidence_ceiling), + "low", + "an unprofiled record stays capped at low confidence" + ); + assert!( + analysis + .extraction_profile + .validated_artifact_families + .is_empty(), + "a captured source the profile could not read is not a validated family" + ); +} + +#[test] +fn a_nonzero_exit_code_without_a_terminal_record_is_not_a_confirmed_failure() { + let enforcement = record( + &format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603" + ), + "05:00:06.000+000", + "AppEnforce", + ); + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + client_artifact("synthetic-enforce", "AppEnforce.log"), + enforcement, + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!(phase_name(transaction.phase), "enforce"); + assert_ne!( + classification_name(transaction.classification), + "confirmedFailure", + "a bare AppEnforce exit code is not a root cause" + ); + assert_eq!(state_name(transaction.state), "insufficientEvidence"); + assert_eq!( + transaction.last_successful_phase.map(phase_name), + Some("cache") + ); + assert_eq!( + transaction + .next_artifact + .as_ref() + .map(|request| request.logical_artifact_id.as_str()), + Some("client-app-enforce") + ); + assert!(transaction.key.exit_code.is_none(), "no admitted exit code"); +} + +#[test] +fn the_public_projection_is_camel_case_and_carries_no_private_material() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let json = serde_json::to_value(&analysis) + .unwrap_or_else(|error| panic!("{scenario}: analysis serializes: {error}")); + + for field in [ + "schemaVersion", + "workflow", + "extractionProfile", + "coverage", + "transactions", + "sourceLocalObservations", + "findings", + "coverageGaps", + "artifactRequests", + "correlationHandoff", + ] { + assert!( + json.get(field).is_some(), + "{scenario}: public field {field} is missing" + ); + } + + let mut keys = Vec::new(); + collect_object_keys(&json, &mut keys); + for key in keys { + assert!( + !key.contains('_') && key.chars().next().is_some_and(char::is_lowercase), + "{scenario}: public field {key} is not camelCase" + ); + } + + let text = serde_json::to_string(&analysis).expect("analysis serializes"); + for forbidden in [ + "CONTOSO", + "C:\\\\Users\\\\", + "S-1-", + "Bearer ", + "client_secret", + ] { + assert!( + !text.contains(forbidden), + "{scenario}: public projection contains {forbidden}" + ); + } + } +} + +fn collect_object_keys(value: &Value, keys: &mut Vec) { + match value { + Value::Object(object) => { + for (key, child) in object { + keys.push(key.clone()); + collect_object_keys(child, keys); + } + } + Value::Array(array) => { + for child in array { + collect_object_keys(child, keys); + } + } + _ => {} + } +} + +/// Scenarios whose declared `extractionProfile` states what the bundle +/// actually produced rather than the profile's full capability list. +const OBSERVED_KEY_KIND_SCENARIOS: [&str; 8] = [ + "bits-transfer-failure", + "cache-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "incomplete", + "rotation-boundary", + "success", +]; + +/// `rotation-boundary` additionally declares `client-content` as a validated +/// family even though none of its rotation fragments ever formed a record, so +/// its family list is not observation derived. +const OBSERVED_FAMILY_SCENARIOS: [&str; 7] = [ + "bits-transfer-failure", + "cache-failure", + "detection-false-negative", + "dp-content-missing", + "enforcement-exit", + "incomplete", + "success", +]; + +#[test] +fn the_selected_extraction_profile_reports_what_the_bundle_validated() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + let declared = &expected["extractionProfile"]; + let profile = &analysis.extraction_profile; + + assert_eq!( + Some(profile.profile_id.as_str()), + declared["profileId"].as_str(), + "{scenario}: profile id" + ); + assert_eq!( + Some(profile.source_version_prefix.as_str()), + declared["sourceVersionPrefix"].as_str(), + "{scenario}: source version prefix" + ); + assert_eq!( + Some(profile.content_version_required), + declared["contentVersionRequired"].as_bool(), + "{scenario}: content version requirement" + ); + + if OBSERVED_KEY_KIND_SCENARIOS.contains(&scenario) { + let declared_kinds = declared["keyKinds"] + .as_array() + .expect("declared key kinds") + .iter() + .map(|kind| kind.as_str().expect("declared key kind").to_owned()) + .collect::>(); + assert_eq!(profile.key_kinds, declared_kinds, "{scenario}: key kinds"); + } + + if OBSERVED_FAMILY_SCENARIOS.contains(&scenario) { + let declared_families = declared["validatedArtifactFamilies"] + .as_array() + .expect("declared validated families") + .iter() + .map(|family| family.as_str().expect("declared family").to_owned()) + .collect::>(); + assert_eq!( + profile.validated_artifact_families, declared_families, + "{scenario}: validated artifact families" + ); + } + + for family in &profile.validated_artifact_families { + assert!( + analysis + .coverage + .iter() + .any(|row| &row.logical_artifact_id == family), + "{scenario}: validated family {family} has no coverage row" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Review defects: a fragment is not a record, ambiguity is not a choice, a +// boundary miss is not a discard, and a group representative may not speak for +// evidence it does not represent. +// --------------------------------------------------------------------------- + +const OTHER_ASSIGNMENT: &str = "10000000-0000-0000-0000-0000000000c1"; +const OTHER_CONTENT: &str = "30000000-0000-0000-0000-0000000000c3"; +const OTHER_REQUEST: &str = "40000000-0000-0000-0000-0000000000c4"; + +fn rotated_artifact(artifact_id: &str, basename: &str, rotation: SccmRotation) -> SccmArtifact { + let mut artifact = client_artifact(artifact_id, basename); + artifact.rotation = rotation; + artifact +} + +fn intent_record() -> String { + record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ) +} + +#[test] +fn a_physical_fragment_inside_a_captured_artifact_never_becomes_a_fact() { + let content = format!( + "{}Requirements satisfied assignmentId={ASSIGNMENT} ciId={CI}\n", + intent_record() + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!( + phase_name(transaction.phase), + "requirements", + "an unframed line cannot satisfy requirements" + ); + assert_eq!( + transaction.last_successful_phase.map(phase_name), + Some("intent"), + "a fragment cannot promote the last successful phase" + ); + assert!( + analysis + .source_local_observations + .iter() + .any(|observation| observation.artifact_id == "synthetic-intent" + && !observation.complete_logical_record), + "the fragment must still be visible as a source-local observation" + ); +} + +#[test] +fn a_physical_fragment_never_confirms_a_terminal_failure() { + let content = format!( + "{}Requirements terminal failure assignmentId={ASSIGNMENT} ciId={CI} requirementId=REQ-TEST-901 terminal=true\n", + intent_record() + ); + let bundle = bundle_from(vec![( + client_artifact("synthetic-intent", "AppIntentEval.log"), + content, + )]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_ne!( + classification_name(transaction.classification), + "confirmedFailure", + "an unframed line cannot confirm a terminal failure" + ); + assert_ne!(confidence_name(transaction.confidence), "high"); +} + +#[test] +fn an_ambiguous_content_request_is_never_published_cross_side() { + let located = |content_id: &str, request_id: &str, package: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId={package} contentId={content_id} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={request_id} siteCode=LAB" + ), + "05:00:02.000+000", + "CAS", + ) + }; + + for (label, first_id, second_id) in [ + ("alphabetical", "synthetic-content-a", "synthetic-content-b"), + ("renamed", "synthetic-content-z", "synthetic-content-y"), + ] { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact(first_id, "CAS.log"), + located(CONTENT, REQUEST, "LAB00021"), + ), + ( + rotated_artifact(second_id, "CAS.log.1", SccmRotation::Numbered(1)), + located(OTHER_CONTENT, OTHER_REQUEST, "LAB00022"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_eq!( + key_profile_name(transaction.key.key_profile_kind), + "assignmentCi", + "{label}: an ambiguous topology cannot key a transaction" + ); + assert!(transaction.key.content_id.is_none(), "{label}: content id"); + assert!( + transaction.counterpart_ready_fact.is_none(), + "{label}: an ambiguous content request must never be published cross-side" + ); + assert!( + !analysis.correlation_handoff.emitted_counterpart_ready_fact, + "{label}: correlation handoff flag" + ); + } +} + +#[test] +fn a_punctuation_adjacent_duplicate_label_is_ambiguity_not_a_first_win() { + let cases = [ + ( + "terminal", + format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603 terminal=true (terminal=false)" + ), + ), + ( + "exit code", + format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603 (exitCode=0) terminal=true" + ), + ), + ]; + + for (label, message) in cases { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + client_artifact("synthetic-enforce", "AppEnforce.log"), + record(&message, "05:00:06.000+000", "AppEnforce"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert_ne!( + classification_name(transaction.classification), + "confirmedFailure", + "{label}: a conflicting duplicate label cannot confirm a failure" + ); + assert_ne!( + confidence_name(transaction.confidence), + "high", + "{label}: confidence" + ); + } +} + +#[test] +fn a_chronology_finding_never_speaks_for_another_phase() { + let unorderable_requirements = record( + &format!( + "Requirements terminal failure assignmentId={OTHER_ASSIGNMENT} ciId={CI} requirementId=REQ-TEST-902 terminal=true" + ), + "05:00:01.000", + "AppIntentEval", + ); + let other_intent = record( + &format!( + "SYNTHETIC FIXTURE deployment targeted assignmentId={OTHER_ASSIGNMENT} ciId={CI} state=targeted" + ), + "05:00:00.000+000", + "AppIntentEval", + ); + let unorderable_enforce = record( + &format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode=1603 terminal=true" + ), + "05:00:06.000", + "AppEnforce", + ); + + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + format!("{}{other_intent}", intent_content()), + ), + ( + rotated_artifact( + "synthetic-intent-rotated", + "AppIntentEval.log.1", + SccmRotation::Numbered(1), + ), + unorderable_requirements, + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + client_artifact("synthetic-enforce", "AppEnforce.log"), + unorderable_enforce, + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + assert_eq!(analysis.transactions.len(), 2, "two keyed transactions"); + + let mut identifiers = analysis + .findings + .iter() + .map(|finding| finding.finding.finding_id.clone()) + .collect::>(); + let total = identifiers.len(); + identifiers.sort(); + identifiers.dedup(); + assert_eq!( + identifiers.len(), + total, + "finding identities must be unique" + ); + + let mut chronology = analysis + .findings + .iter() + .filter(|finding| { + finding + .finding + .finding_id + .starts_with("deployment-chronology-uncertain") + }) + .map(|finding| { + ( + phase_name(finding.deployment_phase), + finding + .finding + .next_artifacts + .first() + .map(|request| request.logical_id.as_str()) + .unwrap_or("none"), + ) + }) + .collect::>(); + chronology.sort(); + assert_eq!( + chronology, + vec![("enforce", "appEnforce"), ("requirements", "appIntentEval")], + "each unorderable phase must request its own artifact" + ); + + for finding in &analysis.findings { + let phase = finding.deployment_phase; + for reference in &finding.finding.evidence { + let cited_by_this_phase = analysis.transactions.iter().any(|transaction| { + transaction.phase == phase + && transaction + .evidence + .iter() + .any(|cited| cited.artifact_id == reference.artifact_id) + }); + assert!( + cited_by_this_phase, + "{}: cites {} from a transaction at another phase", + finding.finding.finding_id, reference.artifact_id + ); + } + } +} + +#[test] +fn every_equally_terminal_record_is_cited_rather_than_one_elected() { + let enforcement = |exit_code: &str, time: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment enforcement terminal failure assignmentId={ASSIGNMENT} ciId={CI} productCode={PRODUCT} exitCode={exit_code} terminal=true" + ), + time, + "AppEnforce", + ) + }; + + for (label, first_id, second_id) in [ + ("alphabetical", "synthetic-enforce-a", "synthetic-enforce-b"), + ("renamed", "synthetic-enforce-z", "synthetic-enforce-y"), + ] { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content", "CAS.log"), + content_content(), + ), + ( + client_artifact("synthetic-transfer", "DataTransferService.log"), + transfer_content("05:00:03.000+000", "05:00:04.000+000"), + ), + ( + client_artifact(first_id, "AppEnforce.log"), + enforcement("1603", "05:00:06.000+000"), + ), + ( + rotated_artifact(second_id, "AppEnforce.log.1", SccmRotation::Numbered(1)), + enforcement("1618", "05:00:07.000+000"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let finding = analysis + .findings + .iter() + .find(|finding| finding.finding.finding_id == "deployment-enforce-terminal") + .unwrap_or_else(|| panic!("{label}: expected an enforcement terminal finding")); + + let mut cited = finding + .finding + .terminal_evidence + .iter() + .map(|terminal| terminal.reference.artifact_id.clone()) + .collect::>(); + cited.sort(); + let mut expected = vec![first_id.to_owned(), second_id.to_owned()]; + expected.sort(); + assert_eq!( + cited, expected, + "{label}: every equally terminal record must be cited" + ); + + let transaction = only_transaction(&analysis); + assert!( + transaction.key.exit_code.is_none(), + "{label}: two conflicting exit codes cannot key the transaction" + ); + } +} + +fn selection_state_name(state: SccmDeploymentProfileSelectionState) -> &'static str { + match state { + SccmDeploymentProfileSelectionState::Selected => "selected", + SccmDeploymentProfileSelectionState::Unselected => "unselected", + } +} + +#[test] +fn the_extraction_profile_reports_its_selection_state() { + for scenario in SCENARIOS { + let analysis = analyze_client_deployment(&load_bundle(scenario)); + let expected = expected(scenario); + assert_eq!( + selection_state_name(analysis.extraction_profile.selection_state), + expected["extractionProfile"]["selectionState"] + .as_str() + .expect("declared selection state"), + "{scenario}: extraction profile selection state" + ); + } + + let mut unprofiled = client_artifact("synthetic-intent", "AppIntentEval.log"); + unprofiled.configmgr_version = Some("5.00.PROD.9128".to_owned()); + let analysis = analyze_client_deployment(&bundle_from(vec![(unprofiled, intent_content())])); + assert_eq!( + selection_state_name(analysis.extraction_profile.selection_state), + "unselected", + "no client source declares the profiled version" + ); +} + +#[test] +fn a_repeated_identical_content_request_publishes_the_earliest_record_only() { + let located = |time: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId=LAB00021 contentId={CONTENT} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={REQUEST} siteCode=LAB" + ), + time, + "CAS", + ) + }; + + // The two records carry the same exact key, so the transaction key is not + // ambiguous. Only the citation is in question, and it must follow the + // records rather than the artifact names. + for (label, early_id, late_id) in [ + ( + "early sorts first", + "synthetic-content-a", + "synthetic-content-b", + ), + ( + "early sorts last", + "synthetic-content-z", + "synthetic-content-a", + ), + ] { + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact(early_id, "CAS.log"), + located("05:00:02.000+000"), + ), + ( + rotated_artifact(late_id, "CAS.log.1", SccmRotation::Numbered(1)), + located("05:00:09.000+000"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + let fact = transaction + .counterpart_ready_fact + .as_ref() + .unwrap_or_else(|| panic!("{label}: an unambiguous repeated request is publishable")); + assert_eq!( + fact.evidence.artifact_id, early_id, + "{label}: the citation must follow chronology, not the artifact name" + ); + assert_eq!( + fact.timestamp_provenance.normalized_utc, "2026-07-30T05:00:02Z", + "{label}: published instant" + ); + } +} + +#[test] +fn an_unorderable_repeated_content_request_is_never_published() { + let located = |time: &str| { + record( + &format!( + "SYNTHETIC FIXTURE deployment content located assignmentId={ASSIGNMENT} ciId={CI} packageId=LAB00021 contentId={CONTENT} contentVersion=21 distributionPointHostHandle=safe:dp:lab-dp-02 requestId={REQUEST} siteCode=LAB" + ), + time, + "CAS", + ) + }; + let bundle = bundle_from(vec![ + ( + client_artifact("synthetic-intent", "AppIntentEval.log"), + intent_content(), + ), + ( + client_artifact("synthetic-content-a", "CAS.log"), + located("05:00:02.000+000"), + ), + ( + rotated_artifact( + "synthetic-content-b", + "CAS.log.1", + SccmRotation::Numbered(1), + ), + located("05:00:09.000"), + ), + ]); + + let analysis = analyze_client_deployment(&bundle); + let transaction = only_transaction(&analysis); + assert!( + transaction.counterpart_ready_fact.is_none(), + "two incomparable records cannot decide which instant is published" + ); +} diff --git a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs index 0e5428f44..38fa4442b 100644 --- a/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs +++ b/crates/cmtraceopen-parser/tests/sccm_spine_contract.rs @@ -2,11 +2,12 @@ use cmtraceopen_parser::models::log_entry::{LogFormat, ParserKind, Severity}; use cmtraceopen_parser::parser::detect::detect_parser; use cmtraceopen_parser::sccm::{ classify_artifact_name, declared_source_catalog, extract_keys, extract_signals, - normalize_ccm_artifact, normalize_key, SccmArtifact, SccmArtifactFamily, SccmArtifactRequest, - SccmConfidence, SccmCorrelationKey, SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, - SccmEvidenceRef, SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, - SccmFinding, SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, - SccmFindingValidationError, SccmKeyConfidence, SccmKeyExtractionResult, SccmPhase, SccmRole, + normalize_ccm_artifact, normalize_key, normalize_physical_lines, SccmArtifact, + SccmArtifactFamily, SccmArtifactRequest, SccmConfidence, SccmCorrelationKey, + SccmCorrelationKeyKind, SccmCoverageState, SccmEvidence, SccmEvidenceRef, + SccmExtractionGapKind, SccmExtractionProfile, SccmExtractionProfileMaturity, SccmFinding, + SccmFindingBuilder, SccmFindingClass, SccmFindingCoverageGap, SccmFindingValidationError, + SccmKeyConfidence, SccmKeyExtractionResult, SccmPhase, SccmRecordCompleteness, SccmRole, SccmRotation, SccmSignal, SccmSignalKind, SccmTerminalEvidence, SccmTerminalEvidenceKind, SccmTimeOrderingState, SccmTimestamp, SccmUnknownRotation, MAX_SCCM_ARTIFACT_REQUEST_REASON_CHARS, MAX_SCCM_NEXT_ARTIFACT_REQUESTS, @@ -31,6 +32,7 @@ fn client_policy_artifact() -> SccmArtifact { fn evidence_with_message(message: &str) -> SccmEvidence { SccmEvidence { evidence_id: "client-policy-agent:1-1".into(), + completeness: SccmRecordCompleteness::LogicalRecord, reference: SccmEvidenceRef { artifact_id: "client-policy-agent".into(), entry_id: "client-policy-agent:1-1".into(), @@ -5442,6 +5444,60 @@ fn catalog_rotation_grammar_preserves_unknown_suffix_and_initialism() { assert!(!class.supported_for_diagnosis); } +#[test] +fn physical_line_normalization_emits_exactly_the_lines_no_record_covers() { + // Line 1 is a fragment, lines 2-3 are one multi-line record, line 4 is a + // fragment, line 5 is blank, line 6 is a record, line 7 is a fragment. + let content = concat!( + "leading fragment\n", + "\n", + "interior fragment\n", + " \n", + "\n", + "trailing fragment\n", + ); + + let artifact = client_policy_artifact(); + let records = normalize_ccm_artifact(artifact.clone(), content); + let covered = records + .iter() + .flat_map(|record| { + let start = record.reference.line_start.expect("record line start"); + let end = record.reference.line_end.expect("record line end"); + start..=end + }) + .collect::>(); + assert!( + covered.contains(&2) && covered.contains(&3), + "the fixture must contain a multi-line record so nested spans are exercised" + ); + + let fragments = normalize_physical_lines(&artifact, content); + assert_eq!( + fragments + .iter() + .map(|fragment| fragment.reference.line_start.expect("fragment line start")) + .collect::>(), + vec![1, 4, 7], + "only uncovered, non-blank lines are fragments" + ); + for fragment in &fragments { + assert_eq!( + fragment.completeness, + SccmRecordCompleteness::PhysicalFragment + ); + assert_eq!( + fragment.reference.line_start, fragment.reference.line_end, + "a fragment spans exactly one physical line" + ); + assert!( + !covered.contains(&fragment.reference.line_start.expect("fragment line start")), + "a line a record already covers is never a fragment" + ); + } +} + #[test] fn catalog_requires_exact_producer_roles_for_server_workflow_sources() { let cases = [ @@ -5636,6 +5692,14 @@ fn expected_catalog_tuples() -> Vec { true, true, ), + ( + "StateMessage.log", + SccmRole::Client, + "stateMessage", + SccmArtifactFamily::ClientPolicy, + true, + true, + ), ( "CAS.log", SccmRole::Client,